use super::expand::{ExpandReport, asset_name, type_norm};
use concinnity_asset::cook::EngineDefaults;
pub(crate) const HUD_FONT_NAME: &str = "hud_font";
const HUD_FONT_SIZE_PX: u32 = 20;
const PHYSICS_CONFIG_NAME: &str = "physics_config";
const SKY_SIZE_MAX: f32 = 400.0;
const SKY_FAR_FRACTION: f32 = 0.9;
const CAMERA_FAR_DEFAULT: f32 = 200.0;
pub(crate) fn inject_engine_defaults(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
) -> Result<(), String> {
let toggles = drain_toggles(assets)?;
let renders = assets.iter().any(|v| type_norm(v) == "graphicsconfig");
if toggles.sky {
inject_sky(assets, report)?;
}
if toggles.physics_config {
inject_physics_config(assets, report)?;
}
if !renders {
return Ok(());
}
let has_menu = assets.iter().any(|v| type_norm(v) == "mainmenu");
let has_stat_hud = assets.iter().any(|v| type_norm(v) == "stathud");
if toggles.hud && (has_menu || has_stat_hud) {
inject_hud(
assets,
report,
"hud",
"StatHud",
"stat_hud",
&[
("fps_label", "fps_chip"),
("vram_label", "vram_chip"),
("ram_label", "ram_chip"),
("ev_label", "ev_chip"),
("edr_label", "edr_chip"),
],
)?;
}
if toggles.debug_hud {
inject_hud(
assets,
report,
"debug_hud",
"DebugHud",
"debug_hud",
&[
("passes_label", "passes_chip"),
("mouse_label", "mouse_chip"),
("camera_label", "camera_chip"),
("sys_label", "sys_chip"),
],
)?;
}
if toggles.loading_overlay {
inject_loading_overlay(assets, report)?;
}
if toggles.story_pause_menu {
inject_story_pause_menu(assets, report)?;
}
Ok(())
}
struct LoadingPiece {
field: &'static str,
name: &'static str,
asset_type: &'static str,
args: serde_json::Value,
}
fn loading_overlay_pieces() -> [LoadingPiece; 5] {
let piece = |field, name, asset_type, args| LoadingPiece {
field,
name,
asset_type,
args,
};
[
piece(
"screen",
"loading_screen",
"Screen",
serde_json::json!({ "fade_in_secs": 0.15 }),
),
piece(
"backdrop",
"loading_screen_backdrop",
"Sprite",
serde_json::json!({
"x": 0, "y": 0, "width": 1280, "height": 720,
"tint": [0.0, 0.0, 0.0, 1.0], "fit": "cover",
}),
),
piece(
"track",
"loading_screen_track",
"Sprite",
serde_json::json!({
"x": 400, "y": 600, "width": 480, "height": 8,
"tint": [0.25, 0.25, 0.25, 1.0], "corner_radius": 4,
}),
),
piece(
"fill",
"loading_screen_fill",
"Sprite",
serde_json::json!({
"x": 400, "y": 600, "width": 0, "height": 8,
"tint": [0.92, 0.92, 0.92, 1.0], "corner_radius": 4,
"visible": false,
}),
),
piece(
"label",
"loading_screen_label",
"TextLabel",
serde_json::json!({
"font": HUD_FONT_NAME, "content": "Loading",
"x": 640, "y": 566, "align": "center",
}),
),
]
}
fn inject_loading_overlay(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
) -> Result<(), String> {
let overlay_index = match assets.iter().position(|v| type_norm(v) == "loadingoverlay") {
Some(i) => i,
None => {
let has_scene = assets.iter().any(|v| type_norm(v) == "scene");
let has_streaming = assets.iter().any(|v| type_norm(v) == "streamingconfig");
if !(has_scene && has_streaming) {
return Ok(());
}
if name_claimed(
assets,
report,
"loading_overlay",
"loading_overlay",
"LoadingOverlay",
None,
)? {
return Ok(());
}
inject(
assets,
report,
"loading_overlay",
"loading_overlay",
"LoadingOverlay",
serde_json::json!({}),
);
assets.len() - 1
}
};
let mut needed: Vec<LoadingPiece> = Vec::new();
{
let overlay = &mut assets[overlay_index];
if overlay.get("args").is_none() {
overlay["args"] = serde_json::json!({});
}
let args = overlay
.get_mut("args")
.and_then(|a| a.as_object_mut())
.ok_or_else(|| "LoadingOverlay: args must be an object".to_string())?;
for piece in loading_overlay_pieces() {
let unset = args
.get(piece.field)
.and_then(|v| v.as_str())
.map(|s| s.is_empty())
.unwrap_or(true);
if unset {
args.insert(piece.field.to_string(), serde_json::json!(piece.name));
needed.push(piece);
}
}
}
if let Some(entry) = report
.injected
.iter_mut()
.find(|i| i.asset_type == "LoadingOverlay")
{
entry.args = assets[overlay_index]
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
}
let mut label_injected = false;
for LoadingPiece {
field,
name: piece,
asset_type: piece_type,
args: piece_args,
} in needed
{
if name_claimed(
assets,
report,
"loading_overlay",
piece,
piece_type,
Some(&piece_args),
)? {
continue;
}
inject(
assets,
report,
"loading_overlay",
piece,
piece_type,
piece_args,
);
label_injected |= field == "label";
}
if label_injected {
let font_args = serde_json::json!({ "size_px": HUD_FONT_SIZE_PX });
if !name_claimed(
assets,
report,
"loading_overlay",
HUD_FONT_NAME,
"Font",
Some(&font_args),
)? {
inject(
assets,
report,
"loading_overlay",
HUD_FONT_NAME,
"Font",
font_args,
);
}
}
Ok(())
}
fn inject_story_pause_menu(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
) -> Result<(), String> {
if assets.iter().any(|v| type_norm(v) == "mainmenu") {
return Ok(());
}
let Some(prefix) = assets
.iter()
.find(|v| type_norm(v) == "story")
.map(asset_name)
.filter(|n| !n.is_empty())
else {
return Ok(());
};
let name = format!("{}_pause", prefix);
if name_claimed(assets, report, "story_pause_menu", &name, "MainMenu", None)? {
return Ok(());
}
let title_screen = format!("{}_title", prefix);
let has_title = assets
.iter()
.any(|v| type_norm(v) == "screen" && asset_name(v) == title_screen);
let mut items = vec![
serde_json::json!({ "label": "Resume", "action": "story:pause" }),
serde_json::json!({ "label": "Save", "action": "story:save" }),
serde_json::json!({ "label": "Load", "action": "story:load" }),
serde_json::json!({ "label": "Settings", "action": "story:settings" }),
];
if has_title {
items.push(serde_json::json!({
"label": "Main Menu",
"action": format!("screen:show:{}", title_screen),
}));
}
items.push(serde_json::json!({ "label": "Quit", "action": "quit" }));
let args = serde_json::json!({
"toggle_key": "",
"dim": [0.0, 0.0, 0.0, 0.6],
"settings_profile": "minimal",
"settings_back_action": "story:settings_back",
"items": items,
});
inject(assets, report, "story_pause_menu", &name, "MainMenu", args);
inject(
assets,
report,
"story_pause_menu",
&format!("{}_key", name),
"KeyBinding",
serde_json::json!({ "key": "Escape", "action": "story:pause" }),
);
patch_story_scaffold(assets, prefix.as_str(), &name);
Ok(())
}
fn patch_story_scaffold(assets: &mut [serde_json::Value], story_name: &str, menu_name: &str) {
let pause_screen = menu_name.to_string();
let settings_screen = format!("{}_settings_video", menu_name);
for v in assets.iter_mut() {
if type_norm(v) != "story" || asset_name(v) != story_name {
continue;
}
let Some(args) = v.get_mut("args").and_then(|a| a.as_object_mut()) else {
return;
};
let Some(scaffold) = args
.entry("scaffold")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
scaffold.insert("pause".to_string(), serde_json::json!(pause_screen));
scaffold.insert("settings".to_string(), serde_json::json!(settings_screen));
return;
}
}
fn drain_toggles(assets: &mut Vec<serde_json::Value>) -> Result<EngineDefaults, String> {
let mut found: Option<(String, EngineDefaults)> = None;
let mut result = Vec::with_capacity(assets.len());
for value in assets.drain(..) {
if type_norm(&value) != "enginedefaults" {
result.push(value);
continue;
}
let name = asset_name(&value);
if let Some((first, _)) = &found {
return Err(format!(
"EngineDefaults '{}': the world already declares EngineDefaults '{}'; \
declare at most one",
name, first
));
}
let args = value
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let toggles: EngineDefaults = serde_json::from_value(args)
.map_err(|e| format!("EngineDefaults '{}': invalid args: {}", name, e))?;
found = Some((name, toggles));
}
*assets = result;
Ok(found.map(|(_, t)| t).unwrap_or_default())
}
fn name_claimed(
assets: &mut [serde_json::Value],
report: &mut ExpandReport,
injected_by: &'static str,
name: &str,
asset_type: &str,
default_args: Option<&serde_json::Value>,
) -> Result<bool, String> {
let Some(claim) = assets.iter().find(|v| asset_name(v) == name) else {
return Ok(false);
};
if type_norm(claim) != asset_type.to_lowercase().replace('_', "") {
return Err(format!(
"engine default '{}' ({}) collides with your {} asset of the same name; \
rename that asset or disable the default with an EngineDefaults entry",
name,
asset_type,
claim.get("type").and_then(|t| t.as_str()).unwrap_or("?"),
));
}
let template = default_args.cloned().unwrap_or(serde_json::json!({}));
report.record_shadowed(name, asset_type, injected_by, template.clone());
if default_args.is_some() {
super::shadow::merge_into_authored(assets, name, &template);
}
Ok(true)
}
fn inject(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
injected_by: &'static str,
name: &str,
asset_type: &str,
args: serde_json::Value,
) {
assets.push(serde_json::json!({
"name": name,
"type": asset_type,
"args": args.clone(),
}));
report.record(name, asset_type, args, injected_by);
}
fn chip_args() -> serde_json::Value {
serde_json::json!({
"font": HUD_FONT_NAME,
"scale": 0.7,
"color": [1, 1, 1],
"background": [0.0, 0.18, 0.32, 0.85],
"padding": 5,
})
}
fn inject_hud(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
injected_by: &'static str,
hud_type: &str,
default_name: &str,
fields: &[(&str, &str)],
) -> Result<(), String> {
let hud_type_norm = hud_type.to_lowercase().replace('_', "");
let hud_index = match assets.iter().position(|v| type_norm(v) == hud_type_norm) {
Some(i) => i,
None => {
if name_claimed(assets, report, injected_by, default_name, hud_type, None)? {
return Ok(());
}
inject(
assets,
report,
injected_by,
default_name,
hud_type,
serde_json::json!({}),
);
assets.len() - 1
}
};
let mut needed_chips: Vec<&str> = Vec::new();
{
let hud = &mut assets[hud_index];
if hud.get("args").is_none() {
hud["args"] = serde_json::json!({});
}
let args = hud
.get_mut("args")
.and_then(|a| a.as_object_mut())
.ok_or_else(|| format!("{} '{}': args must be an object", hud_type, default_name))?;
for (field, chip) in fields {
let unset = args
.get(*field)
.and_then(|v| v.as_str())
.map(|s| s.is_empty())
.unwrap_or(true);
if unset {
args.insert(field.to_string(), serde_json::json!(chip));
needed_chips.push(chip);
}
}
}
if let Some(entry) = report
.injected
.iter_mut()
.find(|i| i.name == default_name && i.asset_type == hud_type)
{
entry.args = assets[hud_index]
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
}
let mut chip_injected = false;
let chip_defaults = chip_args();
for chip in needed_chips {
if name_claimed(
assets,
report,
injected_by,
chip,
"TextLabel",
Some(&chip_defaults),
)? {
continue;
}
inject(assets, report, injected_by, chip, "TextLabel", chip_args());
chip_injected = true;
}
if chip_injected {
let font_args = serde_json::json!({ "size_px": HUD_FONT_SIZE_PX });
if !name_claimed(
assets,
report,
injected_by,
HUD_FONT_NAME,
"Font",
Some(&font_args),
)? {
inject(
assets,
report,
injected_by,
HUD_FONT_NAME,
"Font",
font_args,
);
}
}
Ok(())
}
fn inject_sky(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
) -> Result<(), String> {
if !assets.iter().any(|v| type_norm(v) == "environmentmap") {
return Ok(());
}
let has_sky_mesh = assets.iter().any(|v| {
type_norm(v) == "proceduralmesh"
&& v.get("args")
.and_then(|a| a.get("generator"))
.and_then(|g| g.as_str())
== Some("skybox")
});
if has_sky_mesh {
return Ok(());
}
let far = assets
.iter()
.find(|v| type_norm(v) == "camera3d")
.and_then(|v| v.get("args"))
.and_then(|a| a.get("far"))
.and_then(|f| f.as_f64())
.map(|f| f as f32)
.unwrap_or(CAMERA_FAR_DEFAULT);
let size = (far * SKY_FAR_FRACTION).min(SKY_SIZE_MAX);
let mesh_args = serde_json::json!({ "generator": "skybox", "size": size });
let mesh_claimed = name_claimed(
assets,
report,
"sky",
"sky_mesh",
"ProceduralMesh",
Some(&mesh_args),
)?;
if !mesh_claimed {
inject(
assets,
report,
"sky",
"sky_mesh",
"ProceduralMesh",
mesh_args,
);
}
let mat_args =
serde_json::json!({ "roughness": 1.0, "metallic": 0.0, "tint": [1.0, 1.0, 1.0] });
if !name_claimed(
assets,
report,
"sky",
"mat_sky",
"Material",
Some(&mat_args),
)? {
inject(assets, report, "sky", "mat_sky", "Material", mat_args);
}
let prop_args = serde_json::json!({
"mesh": "sky_mesh",
"material": "mat_sky",
"position": [0.0, 0.0, 0.0],
});
if !name_claimed(assets, report, "sky", "sky", "Prop", Some(&prop_args))? {
inject(assets, report, "sky", "sky", "Prop", prop_args);
}
Ok(())
}
fn has_physics_content(assets: &[serde_json::Value]) -> bool {
assets.iter().any(|v| match type_norm(v).as_str() {
"physicsconfig" | "rigidbody" | "propbody" | "triggervolume" => true,
"skinnedmesh" => v
.get("args")
.and_then(|a| a.get("capsule"))
.is_some_and(|c| !c.is_null()),
_ => false,
})
}
fn inject_physics_config(
assets: &mut Vec<serde_json::Value>,
report: &mut ExpandReport,
) -> Result<(), String> {
if assets.iter().any(|v| type_norm(v) == "physicsconfig") || !has_physics_content(assets) {
return Ok(());
}
let args = serde_json::to_value(crate::components::PhysicsConfig::default())
.map_err(|e| format!("PhysicsConfig: default args are not representable: {}", e))?;
if name_claimed(
assets,
report,
"physics_config",
PHYSICS_CONFIG_NAME,
"PhysicsConfig",
None,
)? {
return Ok(());
}
inject(
assets,
report,
"physics_config",
PHYSICS_CONFIG_NAME,
"PhysicsConfig",
args,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn world(lines: &[serde_json::Value]) -> Vec<serde_json::Value> {
lines.to_vec()
}
fn gfx() -> serde_json::Value {
serde_json::json!({"name":"gfx","type":"GraphicsConfig","args":{}})
}
fn names_of_type(assets: &[serde_json::Value], t: &str) -> Vec<String> {
assets
.iter()
.filter(|v| type_norm(v) == t)
.map(asset_name)
.collect()
}
#[test]
fn non_rendering_world_gets_no_defaults() {
let mut assets = world(&[serde_json::json!({"name":"w","type":"Window","args":{}})]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(assets.len(), 1);
assert!(report.injected.is_empty());
}
#[test]
fn rendering_world_gets_debug_hud_but_no_menu_or_stat_hud() {
let mut assets = world(&[gfx()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "mainmenu").is_empty());
assert!(names_of_type(&assets, "stathud").is_empty());
assert_eq!(names_of_type(&assets, "debughud"), vec!["debug_hud"]);
let chips = names_of_type(&assets, "textlabel");
for chip in ["passes_chip", "mouse_chip", "camera_chip", "sys_chip"] {
assert!(chips.contains(&chip.to_string()), "missing {chip}");
}
assert!(!chips.contains(&"fps_chip".to_string()));
assert_eq!(names_of_type(&assets, "font"), vec![HUD_FONT_NAME]);
assert_eq!(report.injected.len(), 1 + 4 + 1);
}
#[test]
fn main_menu_world_also_gets_the_stat_hud_set() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"pause","type":"MainMenu","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "mainmenu"), vec!["pause"]);
assert_eq!(names_of_type(&assets, "stathud"), vec!["stat_hud"]);
assert_eq!(names_of_type(&assets, "debughud"), vec!["debug_hud"]);
let chips = names_of_type(&assets, "textlabel");
for chip in [
"fps_chip",
"vram_chip",
"ram_chip",
"ev_chip",
"edr_chip",
"passes_chip",
"mouse_chip",
"camera_chip",
"sys_chip",
] {
assert!(chips.contains(&chip.to_string()), "missing {chip}");
}
assert_eq!(names_of_type(&assets, "font"), vec![HUD_FONT_NAME]);
assert_eq!(report.injected.len(), 2 + 9 + 1);
}
#[test]
fn authored_stat_hud_gets_unset_labels_filled() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"hud","type":"StatHud","args":{"fps_label":"my_fps"}}),
serde_json::json!({"name":"my_fps","type":"TextLabel","args":{"font":"hud_font"}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let hud = assets.iter().find(|v| type_norm(v) == "stathud").unwrap();
assert_eq!(hud["args"]["fps_label"], serde_json::json!("my_fps"));
assert_eq!(hud["args"]["vram_label"], serde_json::json!("vram_chip"));
let chips = names_of_type(&assets, "textlabel");
assert!(chips.contains(&"vram_chip".to_string()));
assert!(!chips.contains(&"fps_chip".to_string()));
}
#[test]
fn authored_chip_replaces_the_injected_one() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"menu","type":"MainMenu","args":{}}),
serde_json::json!({"name":"fps_chip","type":"TextLabel","args":{"scale":1.4}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let fps_chips: Vec<_> = assets
.iter()
.filter(|v| asset_name(v) == "fps_chip")
.collect();
assert_eq!(fps_chips.len(), 1);
assert_eq!(fps_chips[0]["args"]["scale"], serde_json::json!(1.4));
}
#[test]
fn default_name_on_unrelated_type_is_an_error() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"debug_hud","type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("debug_hud"));
assert!(err.contains("EngineDefaults"));
}
#[test]
fn engine_defaults_flags_opt_out() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"menu","type":"MainMenu","args":{}}),
serde_json::json!({"name":"d","type":"EngineDefaults","args":{
"hud": false, "debug_hud": false
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "stathud").is_empty());
assert!(names_of_type(&assets, "debughud").is_empty());
assert!(names_of_type(&assets, "enginedefaults").is_empty());
}
#[test]
fn duplicate_engine_defaults_is_an_error() {
let mut assets = world(&[
serde_json::json!({"name":"a","type":"EngineDefaults","args":{}}),
serde_json::json!({"name":"b","type":"EngineDefaults","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("at most one"));
}
#[test]
fn environment_map_gets_a_sky_mesh() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "proceduralmesh"), vec!["sky_mesh"]);
assert_eq!(names_of_type(&assets, "material"), vec!["mat_sky"]);
assert_eq!(names_of_type(&assets, "prop"), vec!["sky"]);
}
#[test]
fn sky_size_tracks_camera_far_and_caps_at_showcase_value() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"cam","type":"Camera3D","args":{"far":100.0}}),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let mesh = assets
.iter()
.find(|v| type_norm(v) == "proceduralmesh")
.unwrap();
assert_eq!(mesh["args"]["size"], serde_json::json!(90.0));
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"cam","type":"Camera3D","args":{"far":900.0}}),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let mesh = assets
.iter()
.find(|v| type_norm(v) == "proceduralmesh")
.unwrap();
assert_eq!(mesh["args"]["size"], serde_json::json!(400.0));
}
#[test]
fn authored_skybox_mesh_suppresses_sky_injection() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
serde_json::json!({"name":"dome","type":"ProceduralMesh","args":{"generator":"skybox","size":250.0}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "proceduralmesh"), vec!["dome"]);
assert!(names_of_type(&assets, "prop").is_empty());
}
#[test]
fn engine_defaults_opts_out_of_the_sky() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
serde_json::json!({"name":"d","type":"EngineDefaults","args":{"sky": false}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "proceduralmesh").is_empty());
assert!(names_of_type(&assets, "prop").is_empty());
assert!(names_of_type(&assets, "material").is_empty());
}
#[test]
fn no_environment_map_means_no_sky() {
let mut assets = world(&[gfx()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "proceduralmesh").is_empty());
}
#[test]
fn story_world_injects_a_pause_menu() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":{}}),
serde_json::json!({"name":"tale_title","type":"Screen","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let menu = assets
.iter()
.find(|v| type_norm(v) == "mainmenu")
.expect("pause menu injected");
assert_eq!(asset_name(menu), "tale_pause");
assert_eq!(menu["args"]["settings_profile"], "minimal");
assert_eq!(menu["args"]["toggle_key"], "");
assert_eq!(menu["args"]["settings_back_action"], "story:settings_back");
let alpha = menu["args"]["dim"][3].as_f64().unwrap();
assert!(
alpha > 0.0 && alpha < 1.0,
"pause dim should be translucent"
);
let items = menu["args"]["items"].as_array().unwrap();
let actions: Vec<&str> = items
.iter()
.map(|i| i["action"].as_str().unwrap())
.collect();
for action in [
"story:pause",
"story:save",
"story:load",
"story:settings",
"screen:show:tale_title",
"quit",
] {
assert!(actions.contains(&action), "missing pause action {action}");
}
let labels: Vec<&str> = items.iter().map(|i| i["label"].as_str().unwrap()).collect();
assert_eq!(labels.last(), Some(&"Quit"));
let main_menu = labels.iter().position(|l| *l == "Main Menu").unwrap();
let quit = labels.iter().position(|l| *l == "Quit").unwrap();
assert!(main_menu < quit, "Main Menu should sit above Quit");
let key = assets
.iter()
.find(|v| type_norm(v) == "keybinding" && asset_name(v) == "tale_pause_key")
.expect("Escape binding injected");
assert_eq!(key["args"]["key"], "Escape");
assert_eq!(key["args"]["action"], "story:pause");
let story = assets.iter().find(|v| type_norm(v) == "story").unwrap();
assert_eq!(story["args"]["scaffold"]["pause"], "tale_pause");
assert_eq!(
story["args"]["scaffold"]["settings"],
"tale_pause_settings_video"
);
assert!(names_of_type(&assets, "stathud").is_empty());
}
#[test]
fn story_without_a_title_screen_quits_to_desktop() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let menu = assets
.iter()
.find(|v| type_norm(v) == "mainmenu")
.expect("pause menu injected");
let items = menu["args"]["items"].as_array().unwrap();
let quit = items.last().unwrap();
assert_eq!(quit["action"], "quit");
let labels: Vec<&str> = items.iter().map(|i| i["label"].as_str().unwrap()).collect();
assert!(
!labels.contains(&"Main Menu"),
"no title screen -> no Main Menu item"
);
}
#[test]
fn authored_menu_suppresses_the_story_pause_menu() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":{}}),
serde_json::json!({"name":"tale_title","type":"Screen","args":{}}),
serde_json::json!({"name":"my_menu","type":"MainMenu","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "mainmenu"), vec!["my_menu"]);
}
#[test]
fn engine_defaults_opts_out_of_the_story_pause_menu() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":{}}),
serde_json::json!({"name":"tale_title","type":"Screen","args":{}}),
serde_json::json!({"name":"d","type":"EngineDefaults","args":{
"story_pause_menu": false
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "mainmenu").is_empty());
}
#[test]
fn malformed_story_args_do_not_panic_the_pause_injection() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":[]}),
serde_json::json!({"name":"tale_title","type":"Screen","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(assets.iter().any(|v| type_norm(v) == "mainmenu"));
}
#[test]
fn non_story_world_gets_no_pause_menu() {
let mut assets = world(&[gfx()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "mainmenu").is_empty());
}
#[test]
fn authored_sky_pieces_are_kept_and_the_rest_injected() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
serde_json::json!({"name":"sky_mesh","type":"ProceduralMesh","args":{"generator":"cube"}}),
serde_json::json!({"name":"mat_sky","type":"Material","args":{"roughness":0.2}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let mesh = assets.iter().find(|v| asset_name(v) == "sky_mesh").unwrap();
assert_eq!(mesh["args"]["generator"], "cube");
let mat = assets.iter().find(|v| asset_name(v) == "mat_sky").unwrap();
assert_eq!(mat["args"]["roughness"], 0.2);
assert_eq!(names_of_type(&assets, "prop"), vec!["sky"]);
let shadowed: Vec<&str> = report.shadowed.iter().map(|s| s.name.as_str()).collect();
assert!(shadowed.contains(&"sky_mesh"), "{shadowed:?}");
assert!(shadowed.contains(&"mat_sky"), "{shadowed:?}");
}
#[test]
fn authored_sky_prop_suppresses_the_injected_one() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
serde_json::json!({"name":"sky","type":"Prop","args":{"mesh":"mine"}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let props = names_of_type(&assets, "prop");
assert_eq!(props, vec!["sky"]);
let prop = assets.iter().find(|v| asset_name(v) == "sky").unwrap();
assert_eq!(prop["args"]["mesh"], "mine");
}
#[test]
fn a_sky_name_held_by_another_type_is_an_error() {
for name in ["sky_mesh", "mat_sky", "sky"] {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"env","type":"EnvironmentMap","args":{"generator":"sky"}}),
serde_json::json!({"name": name, "type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains(name), "{name}: {err}");
assert!(err.contains("Window"), "{name}: {err}");
}
}
#[test]
fn a_hud_chip_or_font_name_held_by_another_type_is_an_error() {
for name in ["passes_chip", HUD_FONT_NAME] {
let mut assets = world(&[
gfx(),
serde_json::json!({"name": name, "type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains(name), "{name}: {err}");
}
}
#[test]
fn a_pause_menu_name_held_by_another_type_is_an_error() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"tale","type":"Story","args":{}}),
serde_json::json!({"name":"tale_pause","type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("tale_pause"), "{err}");
}
#[test]
fn an_authored_hud_without_args_is_filled_in() {
let mut assets = world(&[gfx(), serde_json::json!({"name":"hud","type":"StatHud"})]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let hud = assets.iter().find(|v| type_norm(v) == "stathud").unwrap();
assert_eq!(hud["args"]["fps_label"], "fps_chip");
assert!(names_of_type(&assets, "textlabel").contains(&"fps_chip".to_string()));
}
#[test]
fn a_hud_with_non_object_args_is_an_error() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"hud","type":"StatHud","args":[]}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("args must be an object"), "{err}");
}
#[test]
fn engine_defaults_without_args_keeps_every_default_on() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"d","type":"EngineDefaults"}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "debughud"), vec!["debug_hud"]);
}
#[test]
fn malformed_engine_defaults_args_are_an_error() {
let mut assets = world(&[serde_json::json!({
"name":"d","type":"EngineDefaults","args":{"hud":"yes"}
})]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("EngineDefaults 'd'"), "{err}");
assert!(err.contains("invalid args"), "{err}");
}
#[test]
fn a_non_object_scaffold_is_left_alone() {
let mut assets = vec![serde_json::json!({
"name":"tale","type":"Story","args":{"scaffold": 7}
})];
patch_story_scaffold(&mut assets, "tale", "tale_pause");
assert_eq!(assets[0]["args"]["scaffold"], 7);
}
fn scene_and_streaming() -> [serde_json::Value; 2] {
[
serde_json::json!({"name":"town","type":"Scene","args":{}}),
serde_json::json!({"name":"stream","type":"StreamingConfig","args":{}}),
]
}
#[test]
fn scene_streaming_world_gets_the_loading_overlay_set() {
let [scene, streaming] = scene_and_streaming();
let mut assets = world(&[gfx(), scene, streaming]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(
names_of_type(&assets, "loadingoverlay"),
vec!["loading_overlay"]
);
assert_eq!(names_of_type(&assets, "screen"), vec!["loading_screen"]);
let sprites = names_of_type(&assets, "sprite");
for piece in [
"loading_screen_backdrop",
"loading_screen_track",
"loading_screen_fill",
] {
assert!(sprites.contains(&piece.to_string()), "missing {piece}");
}
let labels = names_of_type(&assets, "textlabel");
assert!(labels.contains(&"loading_screen_label".to_string()));
assert_eq!(names_of_type(&assets, "font"), vec![HUD_FONT_NAME]);
let overlay = assets
.iter()
.find(|v| type_norm(v) == "loadingoverlay")
.unwrap();
assert_eq!(overlay["args"]["screen"], "loading_screen");
assert_eq!(overlay["args"]["fill"], "loading_screen_fill");
let entry = report
.injected
.iter()
.find(|i| i.asset_type == "LoadingOverlay")
.unwrap();
assert_eq!(entry.args["backdrop"], "loading_screen_backdrop");
}
#[test]
fn no_scenes_or_no_streaming_means_no_loading_overlay() {
let [scene, streaming] = scene_and_streaming();
for extra in [scene, streaming] {
let mut assets = world(&[gfx(), extra]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "loadingoverlay").is_empty());
assert!(names_of_type(&assets, "screen").is_empty());
}
}
#[test]
fn engine_defaults_opts_out_of_the_loading_overlay() {
let [scene, streaming] = scene_and_streaming();
let mut assets = world(&[
gfx(),
scene,
streaming,
serde_json::json!({"name":"d","type":"EngineDefaults","args":{
"loading_overlay": false
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "loadingoverlay").is_empty());
}
#[test]
fn authored_loading_overlay_gets_unset_refs_filled() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"load","type":"LoadingOverlay","args":{
"backdrop":"my_backdrop"
}}),
serde_json::json!({"name":"my_backdrop","type":"Sprite","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let overlay = assets
.iter()
.find(|v| type_norm(v) == "loadingoverlay")
.unwrap();
assert_eq!(asset_name(overlay), "load");
assert_eq!(overlay["args"]["backdrop"], "my_backdrop");
assert_eq!(overlay["args"]["track"], "loading_screen_track");
let sprites = names_of_type(&assets, "sprite");
assert!(!sprites.contains(&"loading_screen_backdrop".to_string()));
assert!(sprites.contains(&"loading_screen_fill".to_string()));
}
#[test]
fn authored_loading_piece_replaces_the_injected_one() {
let [scene, streaming] = scene_and_streaming();
let mut assets = world(&[
gfx(),
scene,
streaming,
serde_json::json!({"name":"loading_screen_backdrop","type":"Sprite","args":{
"tint":[0.1,0.0,0.0,1.0]
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let backdrops: Vec<_> = assets
.iter()
.filter(|v| asset_name(v) == "loading_screen_backdrop")
.collect();
assert_eq!(backdrops.len(), 1);
assert_eq!(backdrops[0]["args"]["tint"][0], serde_json::json!(0.1));
let shadowed: Vec<&str> = report.shadowed.iter().map(|s| s.name.as_str()).collect();
assert!(
shadowed.contains(&"loading_screen_backdrop"),
"{shadowed:?}"
);
}
#[test]
fn a_loading_piece_name_held_by_another_type_is_an_error() {
let [scene, streaming] = scene_and_streaming();
let mut assets = world(&[
gfx(),
scene,
streaming,
serde_json::json!({"name":"loading_screen","type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains("loading_screen"), "{err}");
}
fn prop_body() -> serde_json::Value {
serde_json::json!({"name":"crate_body","type":"PropBody","args":{"prop_name":"crate_a"}})
}
fn injected_physics(assets: &[serde_json::Value]) -> Option<&serde_json::Value> {
assets.iter().find(|v| type_norm(v) == "physicsconfig")
}
#[test]
fn physics_content_without_a_config_gets_the_default_one() {
let mut assets = world(&[gfx(), prop_body()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let config = injected_physics(&assets).expect("PhysicsConfig injected");
assert_eq!(asset_name(config), PHYSICS_CONFIG_NAME);
assert_eq!(config["args"]["spawn_headroom"], serde_json::json!(0));
assert_eq!(config["args"]["floor_y"], serde_json::json!(0.0));
assert_eq!(config["args"]["layers"], serde_json::json!([]));
let entry = report
.injected
.iter()
.find(|i| i.asset_type == "PhysicsConfig")
.expect("recorded in the lock");
assert_eq!(entry.injected_by, "physics_config");
assert_eq!(entry.args["spawn_headroom"], serde_json::json!(0));
}
#[test]
fn the_injected_args_deserialize_to_the_engine_default() {
let mut assets = world(&[gfx(), prop_body()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let args = injected_physics(&assets).unwrap()["args"].clone();
let parsed: crate::components::PhysicsConfig = serde_json::from_value(args).unwrap();
let default = crate::components::PhysicsConfig::default();
assert_eq!(parsed.floor_y, default.floor_y);
assert_eq!(parsed.terrain_half_width, default.terrain_half_width);
assert_eq!(parsed.terrain_half_depth, default.terrain_half_depth);
assert_eq!(parsed.terrain_subdivisions, default.terrain_subdivisions);
assert_eq!(parsed.terrain_amplitude, default.terrain_amplitude);
assert_eq!(parsed.terrain_offset_y, default.terrain_offset_y);
assert_eq!(parsed.terrain_mesh, default.terrain_mesh);
assert_eq!(parsed.layers, default.layers);
assert_eq!(parsed.no_collide, default.no_collide);
assert_eq!(parsed.contact_min_impulse, default.contact_min_impulse);
assert_eq!(parsed.spawn_headroom, default.spawn_headroom);
}
#[test]
fn an_authored_physics_config_is_left_alone() {
let mut assets = world(&[
gfx(),
prop_body(),
serde_json::json!({"name":"physics","type":"PhysicsConfig","args":{
"spawn_headroom": 8
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(names_of_type(&assets, "physicsconfig"), vec!["physics"]);
let config = injected_physics(&assets).unwrap();
assert_eq!(config["args"]["spawn_headroom"], serde_json::json!(8));
assert!(
!report
.injected
.iter()
.any(|i| i.asset_type == "PhysicsConfig")
);
}
#[test]
fn a_world_without_physics_content_gets_no_config() {
let mut assets = world(&[gfx()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "physicsconfig").is_empty());
}
#[test]
fn each_gating_asset_triggers_the_injection() {
let gates = [
serde_json::json!({"name":"hero_body","type":"RigidBody","args":{}}),
prop_body(),
serde_json::json!({"name":"gate","type":"TriggerVolume","args":{}}),
serde_json::json!({"name":"hero","type":"SkinnedMesh","args":{
"capsule": {"half_height": 0.9, "radius": 0.3}
}}),
];
for gate in gates {
let label = asset_name(&gate);
let mut assets = world(&[gfx(), gate]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(
names_of_type(&assets, "physicsconfig"),
vec![PHYSICS_CONFIG_NAME],
"{label} should turn physics on"
);
}
}
#[test]
fn a_skinned_mesh_without_a_capsule_is_not_physics_content() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"banner","type":"SkinnedMesh","args":{"capsule":null}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "physicsconfig").is_empty());
}
#[test]
fn a_non_rendering_physics_world_still_gets_the_config() {
let mut assets = world(&[prop_body()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(
names_of_type(&assets, "physicsconfig"),
vec![PHYSICS_CONFIG_NAME]
);
}
#[test]
fn engine_defaults_opts_out_of_the_physics_config() {
let mut assets = world(&[
gfx(),
prop_body(),
serde_json::json!({"name":"d","type":"EngineDefaults","args":{
"physics_config": false
}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert!(names_of_type(&assets, "physicsconfig").is_empty());
}
#[test]
fn the_physics_config_name_held_by_another_type_is_an_error() {
let mut assets = world(&[
gfx(),
prop_body(),
serde_json::json!({"name": PHYSICS_CONFIG_NAME, "type":"Window","args":{}}),
]);
let mut report = ExpandReport::default();
let err = inject_engine_defaults(&mut assets, &mut report).unwrap_err();
assert!(err.contains(PHYSICS_CONFIG_NAME), "{err}");
assert!(err.contains("Window"), "{err}");
}
#[test]
fn injecting_twice_yields_one_physics_config() {
let mut assets = world(&[gfx(), prop_body()]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
inject_engine_defaults(&mut assets, &mut report).unwrap();
assert_eq!(
names_of_type(&assets, "physicsconfig"),
vec![PHYSICS_CONFIG_NAME]
);
}
#[test]
fn synthesized_hud_report_args_include_filled_labels() {
let mut assets = world(&[
gfx(),
serde_json::json!({"name":"menu","type":"MainMenu","args":{}}),
]);
let mut report = ExpandReport::default();
inject_engine_defaults(&mut assets, &mut report).unwrap();
let entry = report
.injected
.iter()
.find(|i| i.asset_type == "StatHud")
.unwrap();
assert_eq!(entry.args["fps_label"], serde_json::json!("fps_chip"));
}
}