use super::app_config::apply_app_config;
use super::camera_shot::expand_camera_shots;
use super::character_model::expand_character_models;
use super::companion::inject_companions;
use super::defaults::inject_engine_defaults;
use super::light_rig::expand_light_rigs;
use super::main_menu::expand_main_menus;
use super::material_palette::expand_material_palettes;
use super::option_select::expand_option_selects;
use super::panel::expand_panels;
use super::prefab::expand_prefabs;
use super::room::expand_room_textures;
use super::scene_import::expand_scene_imports;
use super::slider::expand_sliders;
use super::story::expand_stories;
use crate::world::load_world;
use std::path::Path;
pub(crate) fn type_norm(v: &serde_json::Value) -> String {
v.get("type")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_lowercase()
.replace('_', "")
}
pub(crate) fn asset_name(v: &serde_json::Value) -> String {
asset_name_str(v).to_string()
}
pub(crate) fn asset_name_str(v: &serde_json::Value) -> &str {
v.get("name").and_then(|n| n.as_str()).unwrap_or("")
}
#[derive(Debug, Clone)]
pub struct InjectedAsset {
pub name: String,
pub asset_type: String,
pub args: serde_json::Value,
pub injected_by: &'static str,
}
#[derive(Debug, Clone)]
pub struct GeneratedAsset {
pub name: String,
pub asset_type: String,
pub generated_by: String,
}
#[derive(Debug, Clone)]
pub struct ShadowedAsset {
pub name: String,
pub asset_type: String,
pub generated_by: String,
pub args: serde_json::Value,
}
#[derive(Debug, Default)]
pub(crate) struct ExpandReport {
pub injected: Vec<InjectedAsset>,
pub generated: Vec<GeneratedAsset>,
pub shadowed: Vec<ShadowedAsset>,
}
impl ExpandReport {
pub(crate) fn record(
&mut self,
name: &str,
asset_type: &str,
args: serde_json::Value,
injected_by: &'static str,
) {
self.injected.push(InjectedAsset {
name: name.to_string(),
asset_type: asset_type.to_string(),
args,
injected_by,
});
}
pub(crate) fn record_generated(&mut self, name: &str, asset_type: &str, generated_by: &str) {
self.generated.push(GeneratedAsset {
name: name.to_string(),
asset_type: asset_type.to_string(),
generated_by: generated_by.to_string(),
});
}
pub(crate) fn record_shadowed(
&mut self,
name: &str,
asset_type: &str,
generated_by: &str,
args: serde_json::Value,
) {
if self.shadowed.iter().any(|s| s.name == name) {
return;
}
self.shadowed.push(ShadowedAsset {
name: name.to_string(),
asset_type: asset_type.to_string(),
generated_by: generated_by.to_string(),
args,
});
}
}
pub(crate) fn expand_world(
assets: &mut Vec<serde_json::Value>,
assets_dir: Option<&Path>,
) -> Result<ExpandReport, String> {
let mut report = ExpandReport::default();
let authored: std::collections::HashMap<String, String> = assets
.iter()
.map(|v| {
(
asset_name(v),
v.get("type")
.and_then(|t| t.as_str())
.unwrap_or("?")
.to_string(),
)
})
.filter(|(n, _)| !n.is_empty())
.collect();
expand_scene_imports(assets, &mut report, assets_dir)?;
expand_stories(assets)?;
expand_camera_shots(assets, assets_dir);
expand_character_models(assets)?;
expand_light_rigs(assets, assets_dir);
expand_material_palettes(assets, assets_dir);
expand_prefabs(assets, &authored, &mut report, assets_dir)?;
expand_room_textures(assets);
inject_companions(assets, &mut report);
apply_app_config(assets, &mut report)?;
inject_engine_defaults(assets, &mut report)?;
expand_main_menus(assets)?;
expand_option_selects(assets)?;
expand_sliders(assets)?;
expand_panels(assets)?;
inject_companions(assets, &mut report);
Ok(report)
}
pub fn expand_world_from_str(
content: &str,
assets_dir: Option<&Path>,
) -> std::io::Result<Vec<serde_json::Value>> {
let mut assets = load_world(content)
.map_err(|errs| std::io::Error::new(std::io::ErrorKind::InvalidData, errs.join("\n")))?;
let _ = expand_world(&mut assets, assets_dir)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(assets)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_norm_lowercases_and_strips_underscores() {
let v = serde_json::json!({"type": "MaterialPalette"});
assert_eq!(type_norm(&v), "materialpalette");
}
#[test]
fn type_norm_handles_underscored_type() {
let v = serde_json::json!({"type": "Camera3D"});
assert_eq!(type_norm(&v), "camera3d");
}
#[test]
fn type_norm_missing_type_returns_empty() {
let v = serde_json::json!({"name": "x"});
assert_eq!(type_norm(&v), "");
}
#[test]
fn asset_name_extracts_name() {
let v = serde_json::json!({"name": "my_asset", "type": "Logger"});
assert_eq!(asset_name(&v), "my_asset");
}
#[test]
fn asset_name_missing_returns_empty() {
let v = serde_json::json!({"type": "Logger"});
assert_eq!(asset_name(&v), "");
}
#[test]
fn a_failing_pass_aborts_the_whole_expansion() {
for (asset, needle) in [
(
serde_json::json!({"name":"s","type":"SceneImport","args":{}}),
"SceneImport 's': missing `source`",
),
(
serde_json::json!({"name":"t","type":"StoryImport","args":{}}),
"StoryImport 't': missing `source`",
),
(
serde_json::json!({"name":"p","type":"Prop","args":{"prefab":"ghost"}}),
"prefab 'ghost' not found",
),
(
serde_json::json!({"type":"MainMenu","args":{}}),
"MainMenu: missing `name`",
),
(
serde_json::json!({"type":"OptionSelect","args":{}}),
"OptionSelect: missing `name`",
),
(
serde_json::json!({"type":"Slider","args":{}}),
"Slider: missing `name`",
),
(
serde_json::json!({"type":"Panel","args":{}}),
"Panel: missing `name`",
),
] {
let mut assets = vec![asset.clone()];
let err = expand_world(&mut assets, None).unwrap_err();
assert!(err.contains(needle), "{asset} -> {err}");
}
}
#[test]
fn a_second_engine_defaults_entry_aborts_the_expansion() {
let mut assets = vec![
serde_json::json!({"name":"a","type":"EngineDefaults","args":{}}),
serde_json::json!({"name":"b","type":"EngineDefaults","args":{}}),
];
let err = expand_world(&mut assets, None).unwrap_err();
assert!(err.contains("at most one"), "{err}");
}
#[test]
fn a_window_that_cannot_take_the_app_config_title_aborts_the_expansion() {
let mut assets = vec![
serde_json::json!({"name":"app","type":"AppConfig","args":{"name":"My Game"}}),
serde_json::json!({"name":"win","type":"Window","args":[]}),
];
let err = expand_world(&mut assets, None).unwrap_err();
assert!(err.contains("Window 'win'"), "{err}");
assert!(err.contains("args must be an object"), "{err}");
}
#[test]
fn expand_world_from_str_surfaces_load_and_expansion_errors() {
let malformed = expand_world_from_str("not json at all\n", None).unwrap_err();
assert_eq!(malformed.kind(), std::io::ErrorKind::InvalidData);
assert!(!malformed.to_string().is_empty());
let broken = r#"{"name":"p","type":"Prop","args":{"prefab":"ghost"}}"#;
let err = expand_world_from_str(broken, None).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("ghost"), "{err}");
}
#[test]
fn expand_world_from_str_injects_companions() {
let content = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#;
let assets = expand_world_from_str(content, None).unwrap();
assert!(assets.iter().any(|v| type_norm(v) == "graphicsconfig"));
assert!(assets.iter().any(|v| type_norm(v) == "window"));
}
#[test]
fn bare_main_menu_world_expands_and_pulls_companions() {
let content = r#"{"name":"main_menu","type":"MainMenu"}"#;
let assets = expand_world_from_str(content, None).unwrap();
assert!(!assets.iter().any(|v| type_norm(v) == "mainmenu"));
assert!(assets.iter().any(|v| type_norm(v) == "screen"));
assert!(assets.iter().any(|v| type_norm(v) == "hitregion"));
assert!(assets.iter().any(|v| type_norm(v) == "textlabel"));
assert!(assets.iter().any(|v| type_norm(v) == "graphicsconfig"));
assert!(assets.iter().any(|v| type_norm(v) == "font"));
}
}