Skip to main content

concinnity_cook/build_only/
expand.rs

1// Entry point for all build-time JSON-level world expansion.
2// Operates purely on serde_json::Value; no type registry or blob compilation.
3
4use super::app_config::apply_app_config;
5use super::camera_shot::expand_camera_shots;
6use super::character_model::expand_character_models;
7use super::companion::inject_companions;
8use super::light_rig::expand_light_rigs;
9use super::main_menu::expand_main_menus;
10use super::material_palette::expand_material_palettes;
11use super::menu_defaults::inject_menu_defaults;
12use super::option_select::expand_option_selects;
13use super::panel::expand_panels;
14use super::prefab::expand_prefabs;
15use super::room::expand_room_textures;
16use super::scene_import::expand_scene_imports;
17use super::slider::expand_sliders;
18use super::story::expand_stories;
19
20use crate::authoring::world::load_world;
21
22use std::path::Path;
23
24// Shared helpers used across expansion submodules.
25
26pub(crate) fn type_norm(v: &serde_json::Value) -> String {
27    v.get("type")
28        .and_then(|t| t.as_str())
29        .unwrap_or("")
30        .to_lowercase()
31        .replace('_', "")
32}
33
34pub(crate) fn asset_name(v: &serde_json::Value) -> String {
35    asset_name_str(v).to_string()
36}
37
38// Borrowing form of `asset_name`, for scans that only compare.
39pub(crate) fn asset_name_str(v: &serde_json::Value) -> &str {
40    v.get("name").and_then(|n| n.as_str()).unwrap_or("")
41}
42
43/// One asset added to the world by an injection pass rather than authored or
44/// macro-expanded. Recorded in world-lock.json so the user can see every
45/// default and copy its entry into world.jsonl as an override.
46#[derive(Debug, Clone)]
47pub struct InjectedAsset {
48    /// The injected asset's name.
49    pub name: String,
50    /// The asset's registry type name.
51    pub asset_type: String,
52    /// The args the injection supplied.
53    pub args: serde_json::Value,
54    /// The injection pass (an EngineDefaults flag name or "companion"), so
55    /// listings can say where a default came from.
56    pub injected_by: &'static str,
57}
58
59/// One asset a macro expansion produced from an authored entry, recorded so
60/// listings can group generated assets by what produced them and offer to copy
61/// one into world.jsonl as an override.
62#[derive(Debug, Clone)]
63pub struct GeneratedAsset {
64    /// The generated asset's name.
65    pub name: String,
66    /// The asset's registry type name.
67    pub asset_type: String,
68    /// The authored asset that generated it (a SceneImport's name).
69    pub generated_by: String,
70}
71
72/// One generated asset the world declares itself: the authored entry is a
73/// sparse patch merged over the generated args (see `shadow::merge_args`), so a
74/// line in world.jsonl overrides exactly the fields it names and tracks the
75/// expansion for the rest. Recorded so listings can show the override for what
76/// it is rather than leaving the generated asset unaccounted for.
77#[derive(Debug, Clone)]
78pub struct ShadowedAsset {
79    /// The shadowed asset's name.
80    pub name: String,
81    /// The asset's registry type name.
82    pub asset_type: String,
83    /// The authored asset whose expansion it patches.
84    pub generated_by: String,
85    /// The args the expansion produced before the authored patch was merged:
86    /// the template baseline a per-field override is measured against.
87    pub args: serde_json::Value,
88}
89
90// What the expansion passes added, generated, and skipped during one run.
91#[derive(Debug, Default)]
92pub(crate) struct ExpandReport {
93    pub injected: Vec<InjectedAsset>,
94    pub generated: Vec<GeneratedAsset>,
95    pub shadowed: Vec<ShadowedAsset>,
96}
97
98impl ExpandReport {
99    pub(crate) fn record(
100        &mut self,
101        name: &str,
102        asset_type: &str,
103        args: serde_json::Value,
104        injected_by: &'static str,
105    ) {
106        self.injected.push(InjectedAsset {
107            name: name.to_string(),
108            asset_type: asset_type.to_string(),
109            args,
110            injected_by,
111        });
112    }
113
114    pub(crate) fn record_generated(&mut self, name: &str, asset_type: &str, generated_by: &str) {
115        self.generated.push(GeneratedAsset {
116            name: name.to_string(),
117            asset_type: asset_type.to_string(),
118            generated_by: generated_by.to_string(),
119        });
120    }
121
122    // Idempotent: a name can be checked by more than one pass (both HUDs test the
123    // shared font), and the same override must not be listed twice. The first
124    // record's args win: the earliest pass to produce the asset is its template.
125    pub(crate) fn record_shadowed(
126        &mut self,
127        name: &str,
128        asset_type: &str,
129        generated_by: &str,
130        args: serde_json::Value,
131    ) {
132        if self.shadowed.iter().any(|s| s.name == name) {
133            return;
134        }
135        self.shadowed.push(ShadowedAsset {
136            name: name.to_string(),
137            asset_type: asset_type.to_string(),
138            generated_by: generated_by.to_string(),
139            args,
140        });
141    }
142}
143
144// Run all expansion passes in order. Mutates the asset list in place and
145// reports what the injection passes added. `assets_dir` is the asset search
146// root the source-reading passes (scene imports, presets) resolve against.
147// Returns an error only when a hard failure occurs (e.g. prefab cycle or
148// missing prefab reference).
149pub(crate) fn expand_world(
150    assets: &mut Vec<serde_json::Value>,
151    assets_dir: Option<&Path>,
152) -> Result<ExpandReport, String> {
153    let mut report = ExpandReport::default();
154    // The assets the world declares itself, snapshotted before any pass runs:
155    // a generated entry landing on one of these names is the user's patch of
156    // it, while a collision with anything added later is a conflict between
157    // two expansions.
158    let authored: std::collections::HashMap<String, String> = assets
159        .iter()
160        .map(|v| {
161            (
162                asset_name(v),
163                v.get("type")
164                    .and_then(|t| t.as_str())
165                    .unwrap_or("?")
166                    .to_string(),
167            )
168        })
169        .filter(|(n, _)| !n.is_empty())
170        .collect();
171    // Imports expand first so the assets they generate (materials, meshes,
172    // props, a framed camera) flow through every later pass, including
173    // companion injection.
174    expand_scene_imports(assets, &mut report, assets_dir)?;
175    // Stories expand to External UI assets (Screens, TextLabels, HitRegions)
176    // that need no further expansion but must exist before companion
177    // injection so their TextLabels pull in GraphicsConfig + Font companions.
178    expand_stories(assets)?;
179    expand_camera_shots(assets, assets_dir);
180    // Character models become the skinned meshes they emit, under their own
181    // names, so every later pass (companions, references) sees a SkinnedMesh.
182    expand_character_models(assets)?;
183    expand_light_rigs(assets, assets_dir);
184    expand_material_palettes(assets, assets_dir);
185    expand_prefabs(assets, &authored, &mut report, assets_dir)?;
186    expand_room_textures(assets);
187    // First companion round: materialize the GraphicsConfig render marker (and
188    // its Window / Shader stack) implied by everything authored or
189    // expanded above, so the defaults pass can key off "this world renders".
190    inject_companions(assets, &mut report);
191    // The AppConfig asset (at most one) names the world for distribution and,
192    // when a Window authored no title, fills it so a running game shows its own
193    // name. Runs after the first companion round so a rendering world's injected
194    // Window is present to receive the title.
195    apply_app_config(assets, &mut report)?;
196    // The engine defaults stated in build-only terms: the StatHud a MainMenu
197    // world drives, and a story world's pause MainMenu. Runs before menu
198    // expansion so an injected MainMenu expands like an authored one. Every
199    // other default is injected at world start.
200    inject_menu_defaults(assets, &mut report)?;
201    // Menus expand to External UI assets (Screen / Sprite / TextLabel /
202    // HitRegion / KeyBinding) that need no further expansion, but whose
203    // TextLabels must still pull in their GraphicsConfig + Font companions, so
204    // this runs before the second companion round.
205    expand_main_menus(assets)?;
206    // Menus emit OptionSelect rows for their settings sub-screen; expand those to
207    // their primitives (TextLabels + HitRegion) before companion injection so
208    // the generated TextLabels pull in their Font.
209    expand_option_selects(assets)?;
210    // Menus also emit Slider rows (continuous settings); expand those to their
211    // primitives (TextLabels + Sprites + HitRegion) on the same footing, before
212    // companion injection.
213    expand_sliders(assets)?;
214    // Panels expand to a background Sprite (+ title TextLabel), also before the
215    // second companion round so those pull in their GraphicsConfig / Font.
216    expand_panels(assets)?;
217    // Second companion round: companions for the assets the defaults and menu
218    // passes added. Idempotent for everything round one already covered.
219    inject_companions(assets, &mut report);
220    Ok(report)
221}
222
223/// Load and structurally validate a world.jsonl string, then run all
224/// expansion passes, resolving bare source filenames under `assets_dir`.
225/// Returns the fully expanded asset list. Does not run semantic validation;
226/// see `crate::build_only::prepare_world` for the full build-pipeline front half.
227pub fn expand_world_from_str(
228    content: &str,
229    assets_dir: Option<&Path>,
230) -> std::io::Result<Vec<serde_json::Value>> {
231    let mut assets = load_world(content)
232        .map_err(|errs| std::io::Error::new(std::io::ErrorKind::InvalidData, errs.join("\n")))?;
233
234    let _ = expand_world(&mut assets, assets_dir)
235        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
236
237    Ok(assets)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn type_norm_lowercases_and_strips_underscores() {
246        let v = serde_json::json!({"type": "MaterialPalette"});
247        assert_eq!(type_norm(&v), "materialpalette");
248    }
249
250    #[test]
251    fn type_norm_handles_underscored_type() {
252        let v = serde_json::json!({"type": "Camera3D"});
253        assert_eq!(type_norm(&v), "camera3d");
254    }
255
256    #[test]
257    fn type_norm_missing_type_returns_empty() {
258        let v = serde_json::json!({"name": "x"});
259        assert_eq!(type_norm(&v), "");
260    }
261
262    #[test]
263    fn asset_name_extracts_name() {
264        let v = serde_json::json!({"name": "my_asset", "type": "Logger"});
265        assert_eq!(asset_name(&v), "my_asset");
266    }
267
268    #[test]
269    fn asset_name_missing_returns_empty() {
270        let v = serde_json::json!({"type": "Logger"});
271        assert_eq!(asset_name(&v), "");
272    }
273
274    // Every pass's failure aborts the run and surfaces its own message, so a
275    // broken entry is reported by the pass that understands it.
276    #[test]
277    fn a_failing_pass_aborts_the_whole_expansion() {
278        for (asset, needle) in [
279            (
280                serde_json::json!({"name":"s","type":"SceneImport","args":{}}),
281                "SceneImport 's': missing `source`",
282            ),
283            (
284                serde_json::json!({"name":"t","type":"StoryImport","args":{}}),
285                "StoryImport 't': missing `source`",
286            ),
287            (
288                serde_json::json!({"name":"p","type":"Prop","args":{"prefab":"ghost"}}),
289                "prefab 'ghost' not found",
290            ),
291            (
292                serde_json::json!({"type":"MainMenu","args":{}}),
293                "MainMenu: missing `name`",
294            ),
295            (
296                serde_json::json!({"type":"OptionSelect","args":{}}),
297                "OptionSelect: missing `name`",
298            ),
299            (
300                serde_json::json!({"type":"Slider","args":{}}),
301                "Slider: missing `name`",
302            ),
303            (
304                serde_json::json!({"type":"Panel","args":{}}),
305                "Panel: missing `name`",
306            ),
307        ] {
308            let mut assets = vec![asset.clone()];
309            let err = expand_world(&mut assets, None).unwrap_err();
310            assert!(err.contains(needle), "{asset} -> {err}");
311        }
312    }
313
314    #[test]
315    fn a_second_engine_defaults_entry_aborts_the_expansion() {
316        let mut assets = vec![
317            serde_json::json!({"name":"a","type":"EngineDefaults","args":{}}),
318            serde_json::json!({"name":"b","type":"EngineDefaults","args":{}}),
319        ];
320        let err = expand_world(&mut assets, None).unwrap_err();
321        assert!(err.contains("at most one"), "{err}");
322    }
323
324    #[test]
325    fn a_window_that_cannot_take_the_app_config_title_aborts_the_expansion() {
326        let mut assets = vec![
327            serde_json::json!({"name":"app","type":"AppConfig","args":{"name":"My Game"}}),
328            serde_json::json!({"name":"win","type":"Window","args":[]}),
329        ];
330        let err = expand_world(&mut assets, None).unwrap_err();
331        assert!(err.contains("Window 'win'"), "{err}");
332        assert!(err.contains("args must be an object"), "{err}");
333    }
334
335    // The string entry point reports both the structural failures `load_world`
336    // finds and the expansion failures that follow it.
337    #[test]
338    fn expand_world_from_str_surfaces_load_and_expansion_errors() {
339        let malformed = expand_world_from_str("not json at all\n", None).unwrap_err();
340        assert_eq!(malformed.kind(), std::io::ErrorKind::InvalidData);
341        assert!(!malformed.to_string().is_empty());
342
343        let broken = r#"{"name":"p","type":"Prop","args":{"prefab":"ghost"}}"#;
344        let err = expand_world_from_str(broken, None).unwrap_err();
345        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
346        assert!(err.to_string().contains("ghost"), "{err}");
347    }
348
349    #[test]
350    fn expand_world_from_str_injects_companions() {
351        let content = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#;
352        let assets = expand_world_from_str(content, None).unwrap();
353        assert!(assets.iter().any(|v| type_norm(v) == "graphicsconfig"));
354        // GraphicsConfig pulls in a Window companion.
355        assert!(assets.iter().any(|v| type_norm(v) == "window"));
356    }
357
358    #[test]
359    fn bare_main_menu_world_expands_and_pulls_companions() {
360        let content = r#"{"name":"main_menu","type":"MainMenu"}"#;
361        let assets = expand_world_from_str(content, None).unwrap();
362        // The MainMenu is gone, replaced by its UI assets.
363        assert!(!assets.iter().any(|v| type_norm(v) == "mainmenu"));
364        assert!(assets.iter().any(|v| type_norm(v) == "screen"));
365        assert!(assets.iter().any(|v| type_norm(v) == "hitregion"));
366        // The generated TextLabels pull in GraphicsConfig + a Font companion.
367        assert!(assets.iter().any(|v| type_norm(v) == "textlabel"));
368        assert!(assets.iter().any(|v| type_norm(v) == "graphicsconfig"));
369        assert!(assets.iter().any(|v| type_norm(v) == "font"));
370    }
371}