Spatial chain for Sprint 23 (#576, #577, #578): - TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with set_tile_kind/tile_kind API, backward-compatible with existing is_walkable/set_walkable - Location YAML tile format: tiles as string arrays (F/W/V/R chars), load_location_tiles() stamps tile data onto WalkabilityMap from ContentStore on production startup - Chunk streaming system: ChunkLoadRadius + ChunkStreamingCadence resources, loads/unloads chunks around player position on cadence. v0.1 radius covers full district (no streaming stutter) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
922 lines
29 KiB
Rust
922 lines
29 KiB
Rust
//! Content discovery and deserialization.
|
|
//!
|
|
//! Reads content.yaml, discovers campaigns and districts via directory
|
|
//! structure, deserializes YAML files into intermediate content types.
|
|
//! Comment-only YAML files (stubs) are skipped gracefully.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::content::types::*;
|
|
|
|
/// All content loaded from disk, organized by district.
|
|
/// Inserted as a bevy Resource after loading completes.
|
|
#[derive(Debug, Default)]
|
|
pub struct ContentStore {
|
|
pub manifest: Option<ContentManifest>,
|
|
pub districts: BTreeMap<String, DistrictContent>,
|
|
}
|
|
|
|
/// Content for a single district.
|
|
#[derive(Debug, Default)]
|
|
pub struct DistrictContent {
|
|
pub meta: Option<DistrictMeta>,
|
|
pub district_path: PathBuf,
|
|
pub pools: Vec<Pool>,
|
|
pub templates: Vec<Template>,
|
|
pub triangles: Vec<Triangle>,
|
|
pub npc_profiles: Vec<NpcProfile>,
|
|
pub locations: Vec<Location>,
|
|
pub routines: Option<RoutineFile>,
|
|
pub dialogue_pools: Vec<DialoguePool>,
|
|
pub monologue_pools: Vec<MonologuePool>,
|
|
}
|
|
|
|
/// Errors that can occur during content loading.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ContentError {
|
|
#[error("IO error: {path}: {source}")]
|
|
Io {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
#[error("YAML parse error: {path}: {source}")]
|
|
Yaml {
|
|
path: PathBuf,
|
|
source: serde_yaml::Error,
|
|
},
|
|
#[error("Content manifest not found at {0}")]
|
|
ManifestNotFound(PathBuf),
|
|
}
|
|
|
|
/// Load all content from the given root directory.
|
|
///
|
|
/// The root should contain `content.yaml` and the campaign directories.
|
|
/// Comment-only YAML stubs are skipped (logged at debug level).
|
|
pub fn load_content(content_root: &Path) -> Result<ContentStore, ContentError> {
|
|
let mut store = ContentStore::default();
|
|
|
|
// 1. Load content manifest
|
|
let manifest_path = content_root.join("content.yaml");
|
|
if !manifest_path.exists() {
|
|
return Err(ContentError::ManifestNotFound(manifest_path));
|
|
}
|
|
let manifest: ContentManifest = load_yaml(&manifest_path)?;
|
|
|
|
// 2. Discover districts for each enabled campaign
|
|
for campaign in &manifest.campaigns {
|
|
if !campaign.enabled {
|
|
tracing::debug!("Skipping disabled campaign: {}", campaign.id);
|
|
continue;
|
|
}
|
|
|
|
let campaign_path = content_root.join(&campaign.path);
|
|
let district_dirs = discover_districts(&campaign_path);
|
|
|
|
for district_dir in district_dirs {
|
|
let district_id = derive_district_id(content_root, &district_dir);
|
|
tracing::info!("Loading district: {} from {:?}", district_id, district_dir);
|
|
|
|
let content = load_district(&district_dir)?;
|
|
store.districts.insert(district_id, content);
|
|
}
|
|
}
|
|
|
|
store.manifest = Some(manifest);
|
|
Ok(store)
|
|
}
|
|
|
|
/// Discover district directories by recursively searching for district.yaml.
|
|
fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
|
|
let mut districts = Vec::new();
|
|
let systems_path = campaign_path.join("systems");
|
|
if systems_path.is_dir() {
|
|
walk_for_districts(&systems_path, &mut districts);
|
|
}
|
|
// Discovery order from fs::read_dir is platform-dependent. Sort the Vec
|
|
// here so districts load in a deterministic order regardless of OS.
|
|
// ContentStore.districts uses BTreeMap for deterministic *iteration* later,
|
|
// but sorted discovery ensures deterministic *load* order (and thus
|
|
// deterministic ID derivation and log output).
|
|
districts.sort();
|
|
districts
|
|
}
|
|
|
|
/// Recursively walk directories looking for district.yaml files.
|
|
fn walk_for_districts(dir: &Path, results: &mut Vec<PathBuf>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
|
|
// Collect and sort entries for deterministic traversal order
|
|
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
|
sorted_entries.sort_by_key(|e| e.file_name());
|
|
|
|
for entry in sorted_entries {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
let district_yaml = path.join("district.yaml");
|
|
if district_yaml.exists() {
|
|
results.push(path);
|
|
} else {
|
|
walk_for_districts(&path, results);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Derive a district ID from its filesystem path.
|
|
/// e.g. campaigns/main/systems/krenn/stations/sova/districts/transit → krenn.sova.transit
|
|
fn derive_district_id(content_root: &Path, district_dir: &Path) -> String {
|
|
let rel = district_dir
|
|
.strip_prefix(content_root)
|
|
.unwrap_or(district_dir);
|
|
let components: Vec<&str> = rel
|
|
.components()
|
|
.filter_map(|c| c.as_os_str().to_str())
|
|
.collect();
|
|
|
|
// Extract meaningful path segments: system, station, district name
|
|
// Path pattern: campaigns/{id}/systems/{system}/stations/{station}/districts/{district}
|
|
let mut parts = Vec::new();
|
|
let mut iter = components.iter().peekable();
|
|
while let Some(&segment) = iter.next() {
|
|
match segment {
|
|
"systems" | "stations" | "districts" => {
|
|
if let Some(&&name) = iter.peek() {
|
|
parts.push(name.to_string());
|
|
iter.next();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if parts.is_empty() {
|
|
// Fallback: use the directory name
|
|
district_dir
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unknown")
|
|
.to_string()
|
|
} else {
|
|
parts.join(".")
|
|
}
|
|
}
|
|
|
|
/// Load all content for a single district directory.
|
|
fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
|
|
let mut content = DistrictContent {
|
|
district_path: district_dir.to_path_buf(),
|
|
..Default::default()
|
|
};
|
|
|
|
// District metadata
|
|
let meta_path = district_dir.join("district.yaml");
|
|
if meta_path.exists() {
|
|
match load_yaml::<DistrictMeta>(&meta_path) {
|
|
Ok(meta) => content.meta = Some(meta),
|
|
Err(e) => tracing::warn!("Failed to parse district metadata: {}", e),
|
|
}
|
|
}
|
|
|
|
// Pools
|
|
let pools_path = district_dir.join("pools.yaml");
|
|
if pools_path.exists() {
|
|
match load_yaml::<PoolFile>(&pools_path) {
|
|
Ok(pool_file) => content.pools = pool_file.pools,
|
|
Err(e) => tracing::warn!("Failed to parse pools: {}", e),
|
|
}
|
|
}
|
|
|
|
// Templates
|
|
let templates_dir = district_dir.join("templates");
|
|
if templates_dir.is_dir() {
|
|
content.templates = load_yaml_dir::<Template>(&templates_dir);
|
|
}
|
|
|
|
// Triangles
|
|
let triangles_dir = district_dir.join("triangles");
|
|
if triangles_dir.is_dir() {
|
|
content.triangles = load_yaml_dir::<Triangle>(&triangles_dir);
|
|
}
|
|
|
|
// NPC profiles
|
|
let npcs_dir = district_dir.join("npcs");
|
|
if npcs_dir.is_dir() {
|
|
content.npc_profiles = load_yaml_dir::<NpcProfile>(&npcs_dir);
|
|
}
|
|
|
|
// Locations
|
|
let locations_dir = district_dir.join("locations");
|
|
if locations_dir.is_dir() {
|
|
content.locations = load_yaml_dir::<Location>(&locations_dir);
|
|
}
|
|
|
|
// Routines
|
|
let routines_path = district_dir.join("routines").join("schedules.yaml");
|
|
if routines_path.exists() {
|
|
match load_yaml::<RoutineFile>(&routines_path) {
|
|
Ok(routines) => content.routines = Some(routines),
|
|
Err(e) => tracing::debug!("Skipping routines (stub or invalid): {}", e),
|
|
}
|
|
}
|
|
|
|
// Dialogue pools
|
|
let dialogue_dir = district_dir.join("dialogue");
|
|
if dialogue_dir.is_dir() {
|
|
content.dialogue_pools = load_yaml_recursive::<DialoguePool>(&dialogue_dir);
|
|
}
|
|
|
|
// Monologue pools
|
|
let monologue_dir = district_dir.join("monologue");
|
|
if monologue_dir.is_dir() {
|
|
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
|
|
}
|
|
|
|
let npc_count = content.npc_profiles.len();
|
|
let triangle_count = content.triangles.len();
|
|
let template_count = content.templates.len();
|
|
let pool_count = content.pools.len();
|
|
let dialogue_count = content.dialogue_pools.len();
|
|
let monologue_count = content.monologue_pools.len();
|
|
|
|
tracing::info!(
|
|
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools",
|
|
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count
|
|
);
|
|
|
|
Ok(content)
|
|
}
|
|
|
|
/// Load and parse a single YAML file.
|
|
fn load_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, ContentError> {
|
|
let text = std::fs::read_to_string(path).map_err(|e| ContentError::Io {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
})?;
|
|
serde_yaml::from_str(&text).map_err(|e| ContentError::Yaml {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
})
|
|
}
|
|
|
|
/// Load all YAML files in a directory (non-recursive), skipping stubs.
|
|
fn load_yaml_dir<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
|
sorted_entries.sort_by_key(|e| e.file_name());
|
|
|
|
let mut results = Vec::new();
|
|
for entry in sorted_entries {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
|
continue;
|
|
}
|
|
match load_yaml::<T>(&path) {
|
|
Ok(item) => results.push(item),
|
|
Err(e) => {
|
|
// Check if this is a comment-only stub
|
|
if is_comment_only_file(&path) {
|
|
tracing::debug!("Skipping stub file: {:?}", path);
|
|
} else {
|
|
tracing::warn!("Failed to parse {:?}: {}", path, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
results
|
|
}
|
|
|
|
/// Load all YAML files recursively under a directory, skipping stubs.
|
|
fn load_yaml_recursive<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
|
|
let mut results = Vec::new();
|
|
walk_yaml_files(dir, &mut |path| match load_yaml::<T>(path) {
|
|
Ok(item) => results.push(item),
|
|
Err(e) => {
|
|
if is_comment_only_file(path) {
|
|
tracing::debug!("Skipping stub: {:?}", path);
|
|
} else {
|
|
tracing::warn!("Failed to parse {:?}: {}", path, e);
|
|
}
|
|
}
|
|
});
|
|
results
|
|
}
|
|
|
|
/// Walk a directory recursively, calling the callback for each .yaml file.
|
|
fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
|
|
let mut sorted_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
|
sorted_entries.sort_by_key(|e| e.file_name());
|
|
|
|
for entry in sorted_entries {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
walk_yaml_files(&path, callback);
|
|
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
|
|
callback(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if a YAML file contains only comments and whitespace (stub file).
|
|
// ---------------------------------------------------------------------------
|
|
// Tile loading (#577)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
|
|
|
|
/// Parse a tile character into a TileKind.
|
|
/// Returns `None` for unrecognized characters.
|
|
fn parse_tile_char(ch: char) -> Option<TileKind> {
|
|
match ch {
|
|
'F' => Some(TileKind::Floor),
|
|
'W' => Some(TileKind::Wall),
|
|
'V' => Some(TileKind::Void),
|
|
'R' => Some(TileKind::Restricted),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
|
|
///
|
|
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
|
|
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
|
|
///
|
|
/// Logs warnings for:
|
|
/// - Row count mismatch vs tile_bounds height
|
|
/// - Column count mismatch vs tile_bounds width
|
|
/// - Unrecognized tile characters
|
|
///
|
|
/// Returns the number of locations that had tile data applied.
|
|
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
|
|
let mut locations_loaded = 0u32;
|
|
|
|
for (_district_id, district) in &store.districts {
|
|
for location in &district.locations {
|
|
if apply_location_tiles(location, walkability) {
|
|
locations_loaded += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
locations_loaded
|
|
}
|
|
|
|
/// Apply tile data from a single Location to the WalkabilityMap.
|
|
/// Returns true if tiles were applied, false if skipped.
|
|
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
|
|
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
|
|
return false;
|
|
};
|
|
|
|
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
|
|
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
|
|
|
|
if tiles.len() != expected_height {
|
|
tracing::warn!(
|
|
"Location '{}': tile row count {} != expected height {} (from tile_bounds)",
|
|
location.canonical_id,
|
|
tiles.len(),
|
|
expected_height,
|
|
);
|
|
}
|
|
|
|
for (row_idx, row) in tiles.iter().enumerate() {
|
|
let y = bounds.y_min + row_idx as i32;
|
|
|
|
if row.len() != expected_width {
|
|
tracing::warn!(
|
|
"Location '{}' row {}: length {} != expected width {}",
|
|
location.canonical_id,
|
|
row_idx,
|
|
row.len(),
|
|
expected_width,
|
|
);
|
|
}
|
|
|
|
for (col_idx, ch) in row.chars().enumerate() {
|
|
let x = bounds.x_min + col_idx as i32;
|
|
let pos = TilePosition::new(x, y, bounds.z);
|
|
|
|
match parse_tile_char(ch) {
|
|
Some(kind) => {
|
|
let walkable = matches!(kind, TileKind::Floor);
|
|
walkability.set_walkable(&pos, walkable);
|
|
walkability.set_tile_kind(&pos, kind);
|
|
}
|
|
None => {
|
|
tracing::warn!(
|
|
"Location '{}' row {} col {}: unrecognized tile char '{}'",
|
|
location.canonical_id,
|
|
row_idx,
|
|
col_idx,
|
|
ch,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
|
|
location.canonical_id,
|
|
expected_width,
|
|
expected_height,
|
|
bounds.x_min,
|
|
bounds.y_min,
|
|
bounds.z,
|
|
);
|
|
|
|
true
|
|
}
|
|
|
|
fn is_comment_only_file(path: &Path) -> bool {
|
|
let Ok(text) = std::fs::read_to_string(path) else {
|
|
return false;
|
|
};
|
|
text.lines()
|
|
.all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn create_temp_content(dir: &Path) {
|
|
// Create content.yaml
|
|
fs::write(
|
|
dir.join("content.yaml"),
|
|
r#"version: "0.1.0"
|
|
campaigns:
|
|
- id: test
|
|
path: campaigns/test
|
|
enabled: true
|
|
discovery:
|
|
districts: "systems/**/districts/*/district.yaml"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Create district directory structure
|
|
let district_dir = dir.join("campaigns/test/systems/alpha/stations/beta/districts/gamma");
|
|
fs::create_dir_all(&district_dir).unwrap();
|
|
|
|
// district.yaml
|
|
fs::write(
|
|
district_dir.join("district.yaml"),
|
|
r#"display_name: "Test District"
|
|
description: "A test district"
|
|
locations: ["loc-a"]
|
|
npc_count: 2
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// pools.yaml
|
|
fs::write(
|
|
district_dir.join("pools.yaml"),
|
|
r#"pools:
|
|
- pool_id: "test:pool_a"
|
|
category: npc_role
|
|
candidates:
|
|
- npc_id: "npc:alice"
|
|
weight: 1
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// triangles/
|
|
let tri_dir = district_dir.join("triangles");
|
|
fs::create_dir_all(&tri_dir).unwrap();
|
|
fs::write(
|
|
tri_dir.join("test-triangle.yaml"),
|
|
r#"canonical_id: test-triangle
|
|
display_name: "Test Triangle"
|
|
members:
|
|
- npc: "npc:alice"
|
|
role: "role-a"
|
|
- npc: "npc:bob"
|
|
role: "role-b"
|
|
- npc: "npc:carol"
|
|
role: "role-c"
|
|
forks: []
|
|
resolution_states: []
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// templates/
|
|
let tpl_dir = district_dir.join("templates");
|
|
fs::create_dir_all(&tpl_dir).unwrap();
|
|
fs::write(
|
|
tpl_dir.join("test-site.yaml"),
|
|
r#"template_id: test-site
|
|
display_name: "Test Site"
|
|
location: loc-a
|
|
role_slots:
|
|
- role: worker
|
|
display_name: "Worker"
|
|
count: 1
|
|
required: true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// npcs/ — one stub, one real
|
|
let npc_dir = district_dir.join("npcs");
|
|
fs::create_dir_all(&npc_dir).unwrap();
|
|
fs::write(
|
|
npc_dir.join("alice.yaml"),
|
|
r#"canonical_id: alice
|
|
display_name: "Alice"
|
|
tier: 1
|
|
pattern: "FRIEND"
|
|
motivation: "HANDLER"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
npc_dir.join("bob.yaml"),
|
|
"# NPC Profile: bob\n# canonical_id: test.bob\n",
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn load_content_discovers_district() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_discover");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
create_temp_content(&dir);
|
|
|
|
let store = load_content(&dir).unwrap();
|
|
assert!(store.manifest.is_some());
|
|
assert_eq!(store.districts.len(), 1);
|
|
|
|
let (id, content) = store.districts.iter().next().unwrap();
|
|
assert_eq!(id, "alpha.beta.gamma");
|
|
assert!(content.meta.is_some());
|
|
assert_eq!(content.meta.as_ref().unwrap().display_name, "Test District");
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn load_content_parses_pools() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_pools");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
create_temp_content(&dir);
|
|
|
|
let store = load_content(&dir).unwrap();
|
|
let content = store.districts.values().next().unwrap();
|
|
assert_eq!(content.pools.len(), 1);
|
|
assert_eq!(content.pools[0].pool_id, "test:pool_a");
|
|
assert_eq!(content.pools[0].candidates.len(), 1);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn load_content_parses_triangles() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_triangles");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
create_temp_content(&dir);
|
|
|
|
let store = load_content(&dir).unwrap();
|
|
let content = store.districts.values().next().unwrap();
|
|
assert_eq!(content.triangles.len(), 1);
|
|
assert_eq!(content.triangles[0].canonical_id, "test-triangle");
|
|
assert_eq!(content.triangles[0].members.len(), 3);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn load_content_skips_stub_npcs() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_stubs");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
create_temp_content(&dir);
|
|
|
|
let store = load_content(&dir).unwrap();
|
|
let content = store.districts.values().next().unwrap();
|
|
// Only alice.yaml should parse; bob.yaml is a stub
|
|
assert_eq!(content.npc_profiles.len(), 1);
|
|
assert_eq!(content.npc_profiles[0].canonical_id, "alice");
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn load_content_parses_templates() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_templates");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
create_temp_content(&dir);
|
|
|
|
let store = load_content(&dir).unwrap();
|
|
let content = store.districts.values().next().unwrap();
|
|
assert_eq!(content.templates.len(), 1);
|
|
assert_eq!(content.templates[0].template_id, "test-site");
|
|
assert_eq!(content.templates[0].role_slots.len(), 1);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_district_id_from_path() {
|
|
let root = Path::new("/content");
|
|
let district =
|
|
Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit");
|
|
let id = derive_district_id(root, district);
|
|
assert_eq!(id, "krenn.sova.transit");
|
|
}
|
|
|
|
#[test]
|
|
fn is_comment_only_detects_stubs() {
|
|
let dir = std::env::temp_dir().join("sr_content_test_comment");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
let stub = dir.join("stub.yaml");
|
|
fs::write(&stub, "# comment\n# another\n").unwrap();
|
|
assert!(is_comment_only_file(&stub));
|
|
|
|
let real = dir.join("real.yaml");
|
|
fs::write(&real, "key: value\n").unwrap();
|
|
assert!(!is_comment_only_file(&real));
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn role_count_range_deserialization() {
|
|
// RoleCount::Range uses untagged enum — verify {min, max} object parses
|
|
let yaml = r#"
|
|
template_id: test
|
|
display_name: "Test"
|
|
role_slots:
|
|
- role: worker
|
|
display_name: "Worker"
|
|
count:
|
|
min: 2
|
|
max: 4
|
|
required: true
|
|
- role: manager
|
|
display_name: "Manager"
|
|
count: 1
|
|
required: true
|
|
"#;
|
|
let template: crate::content::types::Template =
|
|
serde_yaml::from_str(yaml).expect("template with RoleCount::Range should parse");
|
|
assert_eq!(template.role_slots.len(), 2);
|
|
|
|
match &template.role_slots[0].count {
|
|
crate::content::types::RoleCount::Range { min, max } => {
|
|
assert_eq!(*min, 2);
|
|
assert_eq!(*max, 4);
|
|
}
|
|
other => panic!("Expected RoleCount::Range, got {:?}", other),
|
|
}
|
|
|
|
match &template.role_slots[1].count {
|
|
crate::content::types::RoleCount::Fixed(n) => assert_eq!(*n, 1),
|
|
other => panic!("Expected RoleCount::Fixed, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pool_constraint_deserialization() {
|
|
// PoolConstraint uses untagged enum — verify both variants parse
|
|
let yaml = r#"
|
|
pools:
|
|
- pool_id: "test:pool"
|
|
category: npc_role
|
|
constraints:
|
|
- must_be_in_template: "logistics-hub"
|
|
- must_have_pattern: "FRIEND"
|
|
- bonded_character: "smuggler"
|
|
candidates:
|
|
- npc_id: "npc:alice"
|
|
weight: 1
|
|
"#;
|
|
let pool_file: crate::content::types::PoolFile =
|
|
serde_yaml::from_str(yaml).expect("pool with constraints should parse");
|
|
assert_eq!(pool_file.pools.len(), 1);
|
|
assert_eq!(pool_file.pools[0].constraints.len(), 3);
|
|
|
|
// All constraints in this format are key-value strings (plain scalars)
|
|
// which match PoolConstraint::KeyValue
|
|
for constraint in &pool_file.pools[0].constraints {
|
|
match constraint {
|
|
crate::content::types::PoolConstraint::KeyValue(s) => {
|
|
assert!(!s.is_empty());
|
|
}
|
|
crate::content::types::PoolConstraint::Structured(_) => {
|
|
// Structured constraints are also valid
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Tile loading tests (#577)
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
|
|
Location {
|
|
canonical_id: "test-loc".to_string(),
|
|
display_name: "Test Location".to_string(),
|
|
description: None,
|
|
tile_bounds: Some(TileBounds {
|
|
x_min: 0,
|
|
y_min: 0,
|
|
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
|
|
y_max: tiles.len() as i32 - 1,
|
|
z: 0,
|
|
}),
|
|
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
|
|
sightlines: None,
|
|
ambient_sound: None,
|
|
social_site: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_tile_char_all_kinds() {
|
|
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
|
|
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
|
|
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
|
|
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
|
|
assert_eq!(parse_tile_char('X'), None);
|
|
assert_eq!(parse_tile_char(' '), None);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_location_tiles_stamps_walkability() {
|
|
let loc = make_location_with_tiles(vec![
|
|
"FWF",
|
|
"FFF",
|
|
"WFW",
|
|
]);
|
|
let mut map = WalkabilityMap::new(4, 4, 1);
|
|
|
|
let applied = apply_location_tiles(&loc, &mut map);
|
|
assert!(applied);
|
|
|
|
// Row 0: F W F
|
|
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
|
|
|
|
// Row 1: F F F
|
|
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
|
|
|
|
// Row 2: W F W
|
|
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_location_tiles_stamps_tile_kind() {
|
|
let loc = make_location_with_tiles(vec![
|
|
"FWVR",
|
|
]);
|
|
let mut map = WalkabilityMap::new(4, 1, 1);
|
|
|
|
apply_location_tiles(&loc, &mut map);
|
|
|
|
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
|
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
|
|
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
|
|
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_location_tiles_with_offset() {
|
|
let loc = Location {
|
|
canonical_id: "offset-loc".to_string(),
|
|
display_name: "Offset".to_string(),
|
|
description: None,
|
|
tile_bounds: Some(TileBounds {
|
|
x_min: 10,
|
|
y_min: 20,
|
|
x_max: 12,
|
|
y_max: 21,
|
|
z: 0,
|
|
}),
|
|
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
|
|
sightlines: None,
|
|
ambient_sound: None,
|
|
social_site: None,
|
|
};
|
|
let mut map = WalkabilityMap::new(32, 32, 1);
|
|
|
|
apply_location_tiles(&loc, &mut map);
|
|
|
|
// (10,20) = F, (11,20) = W, (12,20) = F
|
|
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
|
|
|
|
// (10,21) = W, (11,21) = F, (12,21) = W
|
|
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_location_tiles_skips_without_tiles() {
|
|
let loc = Location {
|
|
canonical_id: "no-tiles".to_string(),
|
|
display_name: "No Tiles".to_string(),
|
|
description: None,
|
|
tile_bounds: Some(TileBounds {
|
|
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
|
|
}),
|
|
tiles: None,
|
|
sightlines: None,
|
|
ambient_sound: None,
|
|
social_site: None,
|
|
};
|
|
let mut map = WalkabilityMap::new(5, 5, 1);
|
|
assert!(!apply_location_tiles(&loc, &mut map));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_location_tiles_skips_without_bounds() {
|
|
let loc = Location {
|
|
canonical_id: "no-bounds".to_string(),
|
|
display_name: "No Bounds".to_string(),
|
|
description: None,
|
|
tile_bounds: None,
|
|
tiles: Some(vec!["FFF".to_string()]),
|
|
sightlines: None,
|
|
ambient_sound: None,
|
|
social_site: None,
|
|
};
|
|
let mut map = WalkabilityMap::new(5, 5, 1);
|
|
assert!(!apply_location_tiles(&loc, &mut map));
|
|
}
|
|
|
|
#[test]
|
|
fn load_location_tiles_from_store() {
|
|
let mut store = ContentStore::default();
|
|
let mut district = DistrictContent::default();
|
|
district.locations.push(make_location_with_tiles(vec![
|
|
"FW",
|
|
"WF",
|
|
]));
|
|
store.districts.insert("test".to_string(), district);
|
|
|
|
let mut map = WalkabilityMap::new(4, 4, 1);
|
|
let count = load_location_tiles(&store, &mut map);
|
|
|
|
assert_eq!(count, 1);
|
|
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
|
assert!(!map.can_move_to(&TilePosition::new(0, 1, 0)));
|
|
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn location_yaml_with_tiles_deserializes() {
|
|
let yaml = r#"
|
|
canonical_id: test-room
|
|
display_name: "Test Room"
|
|
tile_bounds:
|
|
x_min: 5
|
|
y_min: 10
|
|
x_max: 9
|
|
y_max: 12
|
|
z: 0
|
|
tiles:
|
|
- "FFFFF"
|
|
- "FWWWF"
|
|
- "FFFFF"
|
|
"#;
|
|
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
|
|
assert_eq!(loc.canonical_id, "test-room");
|
|
assert!(loc.tiles.is_some());
|
|
let tiles = loc.tiles.unwrap();
|
|
assert_eq!(tiles.len(), 3);
|
|
assert_eq!(tiles[0], "FFFFF");
|
|
assert_eq!(tiles[1], "FWWWF");
|
|
assert_eq!(tiles[2], "FFFFF");
|
|
}
|
|
}
|