refactor(simulation): move content/ under server/ (#669)

Content files (RON templates, YAML campaigns, gauntlet data) are consumed
exclusively by the server. Colocate them at server/content/ and update all
path references in Rust source, tooling scripts, and schema files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 10:31:20 +01:00
co-authored by Claude Opus 4.6
parent c0669489ff
commit b2ef56440f
73 changed files with 153 additions and 55 deletions
@@ -2,7 +2,7 @@
//
// Schema: server/src/npc/blueprint.rs :: ZoneTypeTemplate
// Ticket: #661 (copy team)
// Validate: tooling/validate-ron content/global/zone-types/industrial_freight.ron zone_type
// Validate: tooling/validate-ron server/content/global/zone-types/industrial_freight.ron zone_type
//
// Sources: D-142 (zone-type template architecture).
//
@@ -2,7 +2,7 @@
//
// Schema: server/src/npc/blueprint.rs :: ZoneTypeTemplate
// Ticket: #661 (copy team)
// Validate: tooling/validate-ron content/global/zone-types/rural_agricultural.ron zone_type
// Validate: tooling/validate-ron server/content/global/zone-types/rural_agricultural.ron zone_type
//
// Sources: D-142 (zone-type template architecture).
//
@@ -1,6 +1,6 @@
# Drama Module Schema — Tier 1 Content (D-023)
# YAML expression of JSON Schema 2020-12
# Validated against this schema: content/modules/tier1/*.yaml
# Validated against this schema: server/content/modules/tier1/*.yaml
#
# Ownership:
# Dramatic structure (this file): Paula
+92 -35
View File
@@ -8,8 +8,8 @@
//!
//! Run twice, compare side-by-side:
//! ```sh
//! cargo run --bin generator_spike -- --zone rural --seed 42
//! cargo run --bin generator_spike -- --zone industrial --seed 42
//! cargo run --bin generator_spike -- --zone-type rural --seed 42
//! cargo run --bin generator_spike -- --zone-type industrial --seed 42
//! ```
//!
//! The test: can you tell which is which from the output alone?
@@ -22,7 +22,7 @@
//! functions that work without ECS. Full ECS integration is deferred.
use std::collections::{BTreeMap, VecDeque};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process;
use clap::Parser;
@@ -31,9 +31,9 @@ use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use settled_reach_server::npc::blueprint::{
assemble_behaviors, BlueprintRelationship, CultureProfile, CulturalMarkers, CulturalValues,
assemble_behaviors, BlueprintRelationship, CulturalMarkers, CulturalValues, CultureProfile,
NamingConventions, NpcBlueprint, NpcWant, RoleSpec, SocialSiteSpec, SpeechPatterns,
SpikeOutput, ZoneSpec,
SpikeOutput, ZoneSpec, ZoneTypeTemplate,
};
use settled_reach_server::npc::PersonalityTrait;
use settled_reach_server::simulation::rng::SimRng;
@@ -48,9 +48,11 @@ use settled_reach_server::simulation::rng::SimRng;
about = "NPC generator proof-of-life — Sprint 25 (#612)"
)]
struct Args {
/// Zone type to generate ("rural" or "industrial").
/// Zone type to generate.
/// Phase 1 (hardcoded): "rural" or "industrial".
/// Phase 2 (--from-files): a zone-type ID like "rural_agricultural" or "industrial_freight".
#[arg(long)]
zone: String,
zone_type: String,
/// Deterministic seed — same seed produces identical output.
#[arg(long)]
@@ -60,8 +62,8 @@ struct Args {
#[arg(long, default_value = "van-maanens-star")]
culture: String,
/// Phase 2: load zone spec and culture from RON files on disk.
/// Requires content/global/<zone>-zone-spec.ron and content/global/culture-<culture>.ron.
/// Phase 2: load zone-type template and culture from RON files on disk.
/// Requires server/content/global/zone-types/<zone_type>.ron and server/content/global/culture-<culture>.ron.
#[arg(long)]
from_files: bool,
@@ -268,21 +270,60 @@ fn hardcoded_van_maanens_star_culture() -> CultureProfile {
// Phase 2: file loading
// ---------------------------------------------------------------------------
fn load_zone_from_file(content_root: &PathBuf, zone_type: &str) -> ZoneSpec {
let path = content_root.join("global").join(format!("{}-zone-spec.ron", zone_type));
fn load_zone_from_file(content_root: &Path, zone_type: &str) -> ZoneSpec {
let path = content_root
.join("global")
.join("zone-types")
.join(format!("{}.ron", zone_type));
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("Error loading zone spec from {:?}: {}", path, e);
eprintln!("Tip: copy team fills this file (ticket #609).");
eprintln!("Error loading zone-type template from {:?}: {}", path, e);
eprintln!("Tip: zone-type RON files live in content/global/zone-types/.");
process::exit(1);
});
ron::from_str(&content).unwrap_or_else(|e| {
eprintln!("Invalid ZoneSpec in {:?}: {}", path, e);
let template: ZoneTypeTemplate = ron::from_str(&content).unwrap_or_else(|e| {
eprintln!("Invalid ZoneTypeTemplate in {:?}: {}", path, e);
process::exit(1);
})
});
zone_type_template_to_zone_spec(template)
}
fn load_culture_from_file(content_root: &PathBuf, culture_id: &str) -> CultureProfile {
let path = content_root.join("global").join(format!("culture-{}.ron", culture_id));
/// Convert a `ZoneTypeTemplate` to a `ZoneSpec` compatible with the spike generation pipeline.
///
/// `ZoneTypeTemplate` roles have no weight (weights live on `LocationSpec`).
/// The spike assigns weight=1 uniformly — all roles equally likely.
fn zone_type_template_to_zone_spec(template: ZoneTypeTemplate) -> ZoneSpec {
ZoneSpec {
zone_type: template.id,
label: template.label,
description: template.description,
economic_level: template.economic_level,
population_density: template.population_density,
roles: template
.roles
.into_iter()
.map(|mut r| {
r.weight = 1; // uniform weight — location specs provide real weights
r
})
.collect(),
social_sites: template
.social_site_types
.into_iter()
.map(|s| SocialSiteSpec {
site_type: s.site_type,
label: String::new(),
roles: s.eligible_roles,
min_npcs: 1,
max_npcs: 6,
})
.collect(),
}
}
fn load_culture_from_file(content_root: &Path, culture_id: &str) -> CultureProfile {
let path = content_root
.join("global")
.join(format!("culture-{}.ron", culture_id));
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("Error loading culture from {:?}: {}", path, e);
eprintln!("Tip: copy team fills this file (ticket #610).");
@@ -386,7 +427,11 @@ fn gen_traits(rng: &mut SimRng, culture: &CultureProfile) -> Vec<PersonalityTrai
/// main-RNG word before reaching the Fisher-Yates step.
fn name_pool_seed(seed: u64, zone_type: &str, culture_id: &str) -> u64 {
let mut h: u64 = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed
for b in zone_type.bytes().chain(std::iter::once(b':')).chain(culture_id.bytes()) {
for b in zone_type
.bytes()
.chain(std::iter::once(b':'))
.chain(culture_id.bytes())
{
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3); // FNV-1a prime
}
@@ -549,7 +594,8 @@ const MAX_FILLER_WORDS: usize = 2;
fn gen_cultural_markers(rng: &mut SimRng, culture: &CultureProfile) -> CulturalMarkers {
let filler_count = if culture.speech.filler_words.len() > 1 {
rng.rng.random_range(1_usize..=MAX_FILLER_WORDS.min(culture.speech.filler_words.len()))
rng.rng
.random_range(1_usize..=MAX_FILLER_WORDS.min(culture.speech.filler_words.len()))
} else {
culture.speech.filler_words.len()
};
@@ -595,7 +641,10 @@ fn gen_relationships(
) -> Vec<BlueprintRelationship> {
use settled_reach_server::npc::blueprint::RelationshipValence;
let other_names: Vec<&String> = all_names.iter().filter(|n| n.as_str() != this_name).collect();
let other_names: Vec<&String> = all_names
.iter()
.filter(|n| n.as_str() != this_name)
.collect();
if other_names.is_empty() {
return vec![];
}
@@ -649,9 +698,7 @@ fn generate_npc_blueprint(
let role = pick_role(rng, zone);
let traits = gen_traits(rng, culture);
let want = gen_want(rng, &traits, role);
let pool = behavior_pools
.entry(role.id.clone())
.or_insert_with(VecDeque::new);
let pool = behavior_pools.entry(role.id.clone()).or_default();
let observable_behaviors = gen_behaviors(rng, role, pool);
let cultural_markers = gen_cultural_markers(rng, culture);
// Relationships and Want tells assigned in later passes.
@@ -807,9 +854,15 @@ fn gen_want_tell(rng: &mut SimRng, npc: &NpcBlueprint) -> Option<String> {
AvoidingSomeone => match avoid_target {
Some(name) => {
let options = [
format!("routes around {}'s usual area without looking toward it", name),
format!(
"routes around {}'s usual area without looking toward it",
name
),
format!("takes the long way past {}'s position", name),
format!("keeps a surface or cluster of people between them and {}", name),
format!(
"keeps a surface or cluster of people between them and {}",
name
),
];
options[rng.rng.random_range(0..options.len())].clone()
}
@@ -870,7 +923,10 @@ fn apply_relationship_behaviors(rng: &mut SimRng, npc: &mut NpcBlueprint) {
}
("friend", _) => format!("drifts toward {} between tasks", target),
("subordinate", RelationshipValence::Negative) => {
format!("complies when {} speaks, but doesn't volunteer anything", target)
format!(
"complies when {} speaks, but doesn't volunteer anything",
target
)
}
("subordinate", _) => format!("defers to {} before moving on", target),
("superior", RelationshipValence::Negative) => {
@@ -992,17 +1048,20 @@ fn main() {
// Load zone spec and culture profile
let (zone, culture) = if args.from_files {
eprintln!("Phase 2: loading from files...");
let zone = load_zone_from_file(&args.content_root, &args.zone);
let zone = load_zone_from_file(&args.content_root, &args.zone_type);
let culture = load_culture_from_file(&args.content_root, &args.culture);
(zone, culture)
} else {
// Phase 1: hardcoded stubs
let zone = match args.zone.as_str() {
let zone = match args.zone_type.as_str() {
"rural" => hardcoded_rural_zone(),
"industrial" => hardcoded_industrial_zone(),
other => {
eprintln!("Unknown zone type: '{}'. Use 'rural' or 'industrial'.", other);
eprintln!("(Phase 2 with --from-files supports additional zone types from disk)");
eprintln!(
"Unknown zone type: '{}'. Use 'rural' or 'industrial'.",
other
);
eprintln!("(Phase 2 with --from-files supports zone-type IDs like 'rural_agricultural')");
process::exit(1);
}
};
@@ -1046,7 +1105,7 @@ fn main() {
// Pre-generate unique names using a derived RNG (#628 fix: first-pick bias).
// Name ordering is a pure function of (seed, zone_type, culture) — independent
// of how many words the main RNG has consumed for NPC count or other purposes.
let name_pool = build_name_pool(args.seed, &args.zone, &culture, npc_count);
let name_pool = build_name_pool(args.seed, &args.zone_type, &culture, npc_count);
// Pre-shuffle behavior pools per role (#629 fix: dedup within a zone run).
// D-139: uses assemble_behaviors() when behavior_primitives are present.
@@ -1055,9 +1114,7 @@ fn main() {
// First pass: generate all NPCs (no relationships yet)
let mut npcs: Vec<NpcBlueprint> = name_pool
.into_iter()
.map(|name| {
generate_npc_blueprint(&mut rng, &zone, &culture, name, &mut behavior_pools)
})
.map(|name| generate_npc_blueprint(&mut rng, &zone, &culture, name, &mut behavior_pools))
.collect();
// Collect all names for relationship pass.
+45 -6
View File
@@ -8,8 +8,8 @@
//!
//! ```sh
//! # Via wrapper script (recommended):
//! tooling/validate-ron content/global/zone-identity-spec.example.ron zone
//! tooling/validate-ron content/global/culture-van-maanens-star.example.ron culture
//! tooling/validate-ron server/content/global/zone-identity-spec.example.ron zone
//! tooling/validate-ron server/content/global/culture-van-maanens-star.example.ron culture
//!
//! # Direct:
//! cargo run --bin validate_ron -- <file.ron> <zone|culture>
@@ -19,7 +19,7 @@ use std::process;
use clap::Parser;
use settled_reach_server::npc::blueprint::{CultureProfile, ZoneSpec};
use settled_reach_server::npc::blueprint::{CultureProfile, ZoneSpec, ZoneTypeTemplate};
#[derive(Parser)]
#[command(
@@ -29,7 +29,7 @@ use settled_reach_server::npc::blueprint::{CultureProfile, ZoneSpec};
struct Args {
/// Path to the RON file to validate.
file: String,
/// Schema type: "zone" (ZoneSpec) or "culture" (CultureProfile).
/// Schema type: "zone" (ZoneSpec), "zone_type" (ZoneTypeTemplate), or "culture" (CultureProfile).
schema: String,
}
@@ -48,7 +48,11 @@ fn main() {
"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());
println!(
" {} roles, {} social sites",
spec.roles.len(),
spec.social_sites.len()
);
let mut warnings = 0;
if spec.roles.is_empty() {
eprintln!(" WARNING: no roles defined — generator will reject this");
@@ -105,8 +109,43 @@ fn main() {
process::exit(1);
}
},
"zone_type" => match ron::from_str::<ZoneTypeTemplate>(&content) {
Ok(template) => {
println!("Valid ZoneTypeTemplate: {} ({})", template.label, template.id);
println!(
" {} roles, {} social site types",
template.roles.len(),
template.social_site_types.len()
);
let mut warnings = 0;
if template.roles.is_empty() {
eprintln!(" WARNING: no roles defined — generator will reject this");
warnings += 1;
}
for role in &template.roles {
if role.behavior_primitives.is_empty() {
eprintln!(
" WARNING: role '{}' has no behavior_primitives",
role.id
);
warnings += 1;
}
}
if warnings > 0 {
process::exit(1);
}
}
Err(e) => {
eprintln!("Invalid ZoneTypeTemplate in {}:", args.file);
eprintln!(" {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown schema type: '{}'. Use 'zone' or 'culture'.", other);
eprintln!(
"Unknown schema type: '{}'. Use 'zone', 'zone_type', or 'culture'.",
other
);
process::exit(1);
}
}
+2 -2
View File
@@ -10,8 +10,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
KNOWLEDGE_DIR="$REPO_ROOT/content/global/knowledge"
CONTENT_DIR="$REPO_ROOT/content/campaigns"
KNOWLEDGE_DIR="$REPO_ROOT/server/content/global/knowledge"
CONTENT_DIR="$REPO_ROOT/server/content/campaigns"
# --- Extract canonical fact_ids from knowledge catalogs ---
# Matches YAML lines like: fact_id: some_value or fact_id: "some_value"
+2 -2
View File
@@ -16,8 +16,8 @@ import jsonschema
import yaml
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_PATH = ROOT / "content" / "_schema" / "checklist.schema.json"
GAUNTLET_DIR = ROOT / "content" / "gauntlet"
SCHEMA_PATH = ROOT / "server" / "content" / "_schema" / "checklist.schema.json"
GAUNTLET_DIR = ROOT / "server" / "content" / "gauntlet"
def load_schema():
+1 -1
View File
@@ -29,7 +29,7 @@ from pathlib import Path
import jsonschema
import yaml
CONTENT_DIR = Path(__file__).resolve().parent.parent / "content"
CONTENT_DIR = Path(__file__).resolve().parent.parent / "server" / "content"
SCHEMA_DIR = CONTENT_DIR / "_schema"
# Map directory parent name (or filename) to schema file
+8 -6
View File
@@ -2,21 +2,23 @@
# RON content validator — wrapper for the Rust validate_ron binary (#611).
#
# Usage:
# tooling/validate-ron <file.ron> <zone|culture>
# tooling/validate-ron <file.ron> <zone|zone_type|culture>
#
# Examples:
# tooling/validate-ron content/global/zone-identity-spec.example.ron zone
# tooling/validate-ron content/global/culture-van-maanens-star.example.ron culture
# tooling/validate-ron server/content/global/zone-types/rural_agricultural.ron zone_type
# tooling/validate-ron server/content/global/culture-van-maanens-star.example.ron culture
# tooling/validate-ron server/content/global/zone-identity-spec.example.ron zone
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: tooling/validate-ron <file.ron> <zone|culture>"
echo "Usage: tooling/validate-ron <file.ron> <zone|zone_type|culture>"
echo ""
echo "Validates a RON file against the Rust struct schema."
echo "Schema types:"
echo " zone — ZoneSpec (zone identity spec)"
echo " culture — CultureProfile (culture profile)"
echo " zone_type — ZoneTypeTemplate (D-142 zone-type template)"
echo " zone — ZoneSpec (legacy zone identity spec)"
echo " culture — CultureProfile (culture profile)"
exit 1
fi