Skip to main content

concinnity_cook/build_only/
mod.rs

1/// The build-time world surface: the expansion passes, preset loading, and the
2/// build front-half orchestrator (prepare_world = load + expand + validate).
3/// The authored world model it works on -- world.jsonl I/O, `WorldJsonlAsset`,
4/// $include resolution, and structural validation (`load_world`) -- is
5/// `crate::authoring::world`.
6pub mod preset;
7
8use crate::authoring::world::{WorldJsonlAsset, load_world};
9
10pub(crate) mod app_config;
11pub(crate) mod camera_shot;
12pub(crate) mod character_model;
13pub(crate) mod companion;
14pub(crate) mod companion_specs;
15
16pub(crate) mod light_rig;
17pub(crate) mod main_menu;
18pub(crate) mod material_palette;
19pub(crate) mod menu_defaults;
20pub(crate) mod option_select;
21pub(crate) mod panel;
22pub(crate) mod prefab;
23pub(crate) mod room;
24pub(crate) mod scene_import;
25pub(crate) mod slider;
26pub(crate) mod story;
27pub use story::validate_story_source;
28pub(crate) mod ui_spec;
29
30pub(crate) mod expand;
31mod provenance;
32pub use provenance::Provenance;
33pub(crate) mod shadow;
34pub use shadow::merge_args;
35
36pub(crate) use expand::expand_world;
37pub use expand::{GeneratedAsset, InjectedAsset, ShadowedAsset, expand_world_from_str};
38/// A world.jsonl that has been loaded, structurally validated, expanded, and
39/// semantically checked: everything the compile stage needs, computed once.
40pub struct LoadedWorld {
41    /// The same assets as typed entries, consumed by the build pipeline.
42    pub assets: Vec<WorldJsonlAsset>,
43    /// Assets added by the injection passes (companions, engine defaults),
44    /// recorded in world-lock.json so the user can see and override them.
45    pub injected: Vec<InjectedAsset>,
46    /// Assets a macro expansion produced, paired with the authored asset that
47    /// produced them, so listings can group them by source.
48    pub generated: Vec<GeneratedAsset>,
49    /// Generated assets the world declares a patch of; the merged result is in
50    /// `assets` and each record carries the pre-merge generated args.
51    pub shadowed: Vec<ShadowedAsset>,
52    /// Names declared in the world file itself (pre-expansion), for
53    /// provenance listings.
54    pub authored: Vec<String>,
55}
56
57/// Run the read-only front half of the build pipeline: parse and structurally
58/// validate the world (`load_world`), expand all build-time assets, then run
59/// semantic validation (`crate::check::check_world`). Returns everything the
60/// compile stage needs, computed exactly once. Errors from every stage are
61/// collected, so the caller gets the full picture in a single pass.
62///
63/// `assets_dir` is the asset search root the expansion passes resolve bare
64/// source filenames and preset names against; `None` leaves them unresolved.
65pub fn prepare_world(
66    content: &str,
67    assets_dir: Option<&std::path::Path>,
68) -> Result<LoadedWorld, Vec<String>> {
69    let mut expanded = load_world(content)?;
70    let authored: Vec<String> = expanded
71        .iter()
72        .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
73        .map(str::to_string)
74        .collect();
75    let report = expand_world(&mut expanded, assets_dir).map_err(|e| vec![e])?;
76    // The expansion is the work this half of the build produces cache entries
77    // for, so its segment is written here rather than left to a compile that a
78    // check-only run never reaches.
79    crate::cache::flush();
80
81    let assets: Vec<WorldJsonlAsset> = expanded.iter().map(WorldJsonlAsset::from_value).collect();
82
83    crate::check::check_world(&assets)?;
84
85    Ok(LoadedWorld {
86        assets,
87        injected: report.injected,
88        generated: report.generated,
89        shadowed: report.shadowed,
90        authored,
91    })
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    // The model-layer tests (load_world, resolve_includes, asset_name_from_path)
99    // live in `crate::authoring::world` with the code; this covers cook's
100    // front-half orchestration on top of it.
101    #[test]
102    fn prepare_world_expands_and_validates() {
103        let content = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#;
104        let loaded = prepare_world(content, None).unwrap();
105        // GraphicsConfig pulls in its companions, so the prepared world holds
106        // more than the single declared asset.
107        assert!(loaded.assets.len() > 1);
108        assert!(
109            loaded
110                .assets
111                .iter()
112                .any(|a| a.asset_type == "GraphicsConfig")
113        );
114        // The authored names are captured before expansion, so the injected
115        // companions are not mistaken for what the world declared.
116        assert_eq!(loaded.authored, vec!["gfx".to_string()]);
117    }
118
119    // An expansion failure is reported as the single error it is, rather than
120    // being swallowed on the way to semantic validation.
121    #[test]
122    fn prepare_world_reports_an_expansion_failure() {
123        let content = r#"{"name":"p","type":"Prop","args":{"prefab":"ghost"}}"#;
124        let errs = prepare_world(content, None).err().unwrap_or_default();
125        assert_eq!(errs.len(), 1);
126        assert!(errs[0].contains("ghost"), "{errs:?}");
127    }
128
129    // Semantic validation runs on the expanded world, so a dangling reference
130    // that survives expansion still fails the build.
131    #[test]
132    fn prepare_world_reports_semantic_errors() {
133        let content = r#"{"name":"prop","type":"Prop","args":{"mesh":"nope"}}"#;
134        let errs = prepare_world(content, None).err().unwrap_or_default();
135        assert!(!errs.is_empty());
136        assert!(errs.iter().any(|e| e.contains("nope")), "{errs:?}");
137    }
138
139    // The asset search root is the caller's, so one world expands differently
140    // under two roots in the same process, and under none it falls back to the
141    // type defaults. This is what the root being a parameter rather than a
142    // process-wide anchor buys.
143    #[test]
144    fn prepare_world_expands_presets_from_the_root_it_is_given() {
145        fn rig_root(intensity: f64) -> tempfile::TempDir {
146            let dir = tempfile::tempdir().unwrap();
147            let rigs = dir.path().join("light_rigs");
148            std::fs::create_dir_all(&rigs).unwrap();
149            std::fs::write(
150                rigs.join("dusk.json"),
151                serde_json::to_vec(&serde_json::json!({
152                    "args": {"lights": [{"kind": "directional", "name": "key", "intensity": intensity}]}
153                }))
154                .unwrap(),
155            )
156            .unwrap();
157            dir
158        }
159        fn key_intensity(loaded: &LoadedWorld) -> Option<f64> {
160            loaded
161                .assets
162                .iter()
163                .find(|a| a.name == "rig_key")?
164                .args
165                .get("intensity")?
166                .as_f64()
167        }
168
169        let content = r#"{"name":"rig","type":"LightRig","args":{"preset":"dusk"}}"#;
170        let bright = rig_root(3.5);
171        let dim = rig_root(0.25);
172
173        assert_eq!(
174            key_intensity(&prepare_world(content, Some(bright.path())).unwrap()),
175            Some(3.5)
176        );
177        assert_eq!(
178            key_intensity(&prepare_world(content, Some(dim.path())).unwrap()),
179            Some(0.25)
180        );
181        // No root: the preset is never found, so the rig expands to nothing.
182        assert_eq!(key_intensity(&prepare_world(content, None).unwrap()), None);
183    }
184}