Skip to main content

dotzuki_runner/
project.rs

1//! Zero-Rust game project loading: manifest → DSL compile → script registry.
2//!
3//! [`LoadedProject::load`] reads the `.dotzuki-editor.json` manifest, compiles
4//! every DSL file under the manifest's `dsl_dirs` (in memory), registers the
5//! compiled scenes with a [`ScriptLoader`], and keeps the storyline routing
6//! table and a scene-name ↔ file-stem index for entry resolution.
7//!
8//! All file access goes through the [`ProjectFiles`] VFS: `load` is the
9//! on-disk convenience ([`DiskFiles`]); [`load_with_files`](Self::load_with_files)
10//! boots a project from any backend (e.g. an in-memory [`MemoryFiles`] on
11//! WASM). DSL `source_path`s are project-relative POSIX paths in both cases
12//! (`data/maps/<id>/script.scene`), which is what the runtime's scene ↔ map
13//! matching keys on.
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use anyhow::{bail, Context, Result};
20use dotzuki_engine_dsl::compiler::{compile_files, CompileReport, RouteEntry, DSL_EXTENSIONS};
21use dotzuki_engine_dsl::loader::register_compiled;
22use dotzuki_engine_script::loader::ScriptLoader;
23
24use crate::manifest::Manifest;
25use crate::map::RuntimeMap;
26use crate::vfs::{join_path, DiskFiles, ProjectFiles};
27
28/// Default maps directory under `dataRoot` when no `map` activity configures
29/// `mapsDir`.
30pub const DEFAULT_MAPS_DIR: &str = "maps";
31
32/// The manifest filename at the project root.
33const MANIFEST_FILE: &str = ".dotzuki-editor.json";
34
35/// A fully loaded zero-Rust game project.
36pub struct LoadedProject {
37    /// The file backend every project read goes through.
38    files: Arc<dyn ProjectFiles>,
39    root: PathBuf,
40    manifest: Manifest,
41    data_root: PathBuf,
42    /// `data_root` as a project-relative POSIX path (VFS key prefix).
43    data_root_rel: String,
44    gfx_root: Option<PathBuf>,
45    /// `gfx_root` as a project-relative POSIX path, when configured.
46    gfx_root_rel: Option<String>,
47    scripts: ScriptLoader,
48    report: CompileReport,
49    /// `.scene` file stem → compiled scene name (`game_scene X`).
50    stem_to_name: HashMap<String, String>,
51    /// Compiled scene name → `.scene` file stem.
52    name_to_stem: HashMap<String, String>,
53}
54
55impl LoadedProject {
56    /// Load the project rooted at `root` (the directory containing
57    /// `.dotzuki-editor.json`) from disk. Convenience for
58    /// [`load_with_files`](Self::load_with_files) over a [`DiskFiles`].
59    ///
60    /// # Errors
61    ///
62    /// Fails on a missing/unparseable manifest or any DSL diagnostic.
63    pub fn load(root: &Path) -> Result<Self> {
64        Self::load_with_files(Arc::new(DiskFiles::new(root)))
65    }
66
67    /// Load the project from a [`ProjectFiles`] backend.
68    ///
69    /// The DSL is compiled in memory; any compiler diagnostic (unreadable
70    /// file, compile failure, route conflict) aborts the load with an error
71    /// listing every diagnostic — the same bar `dotzuki check` enforces.
72    ///
73    /// # Errors
74    ///
75    /// Fails on a missing/unparseable manifest or any DSL diagnostic.
76    pub fn load_with_files(files: Arc<dyn ProjectFiles>) -> Result<Self> {
77        let root: PathBuf = files.root().map(Path::to_path_buf).unwrap_or_default();
78        let bytes = files.read(MANIFEST_FILE).map_err(|_| {
79            anyhow::anyhow!(
80                "no {MANIFEST_FILE} found in {} — not a jrpg game project",
81                if root.as_os_str().is_empty() {
82                    "<memory>".to_string()
83                } else {
84                    root.display().to_string()
85                }
86            )
87        })?;
88        let text = String::from_utf8(bytes).with_context(|| format!("{MANIFEST_FILE} is not UTF-8"))?;
89        let manifest: Manifest = serde_json::from_str(&text)
90            .with_context(|| format!("failed to parse {MANIFEST_FILE}"))?;
91
92        let data_root_rel = join_path("", &manifest.data_root);
93        let gfx_root_rel = manifest.gfx_root.as_deref().map(|g| join_path("", g));
94        let data_root = root.join(&data_root_rel);
95        let gfx_root = gfx_root_rel.as_ref().map(|g| root.join(g));
96
97        let report = compile_project_dsl(files.as_ref(), &manifest);
98        if !report.diagnostics.is_empty() {
99            bail!(
100                "DSL compile failed with {} diagnostic(s):\n  {}",
101                report.diagnostics.len(),
102                report.diagnostics.join("\n  ")
103            );
104        }
105
106        let mut scripts = ScriptLoader::new();
107        register_compiled(&mut scripts, &report);
108
109        let (stem_to_name, name_to_stem) = stem_indexes(&report);
110
111        Ok(Self {
112            files,
113            root,
114            manifest,
115            data_root,
116            data_root_rel,
117            gfx_root,
118            gfx_root_rel,
119            scripts,
120            report,
121            stem_to_name,
122            name_to_stem,
123        })
124    }
125
126    /// Recompile every DSL directory and swap the compiled scenes in place.
127    ///
128    /// On success the script registry, routing table and stem indexes are
129    /// replaced wholesale — a scene currently mid-activation keeps running
130    /// the JS it was started with; the next activation picks up the new
131    /// source. On any compiler diagnostic the old scenes are kept and an
132    /// error listing every diagnostic is returned (same bar as [`load`]).
133    ///
134    /// # Errors
135    ///
136    /// Fails when the recompile produces any diagnostic.
137    ///
138    /// [`load`]: Self::load
139    pub fn recompile_scripts(&mut self) -> Result<()> {
140        let report = compile_project_dsl(self.files.as_ref(), &self.manifest);
141        if !report.diagnostics.is_empty() {
142            bail!(
143                "DSL recompile failed with {} diagnostic(s):\n  {}",
144                report.diagnostics.len(),
145                report.diagnostics.join("\n  ")
146            );
147        }
148
149        let mut scripts = ScriptLoader::new();
150        register_compiled(&mut scripts, &report);
151        let (stem_to_name, name_to_stem) = stem_indexes(&report);
152
153        self.scripts = scripts;
154        self.report = report;
155        self.stem_to_name = stem_to_name;
156        self.name_to_stem = name_to_stem;
157        Ok(())
158    }
159
160    /// The project's file backend.
161    #[inline]
162    pub fn files(&self) -> &Arc<dyn ProjectFiles> {
163        &self.files
164    }
165
166    /// Project root directory (empty for a project without a disk root).
167    #[inline]
168    pub fn root(&self) -> &Path {
169        &self.root
170    }
171
172    /// The parsed `.dotzuki-editor.json` manifest.
173    #[inline]
174    pub fn manifest(&self) -> &Manifest {
175        &self.manifest
176    }
177
178    /// Resolved data root (manifest `dataRoot` against the project root).
179    #[inline]
180    pub fn data_root(&self) -> &Path {
181        &self.data_root
182    }
183
184    /// The data root as a project-relative POSIX path (VFS key prefix).
185    #[inline]
186    pub fn data_root_rel(&self) -> &str {
187        &self.data_root_rel
188    }
189
190    /// Resolved graphics root (manifest `gfxRoot`), when configured.
191    #[inline]
192    pub fn gfx_root(&self) -> Option<&Path> {
193        self.gfx_root.as_deref()
194    }
195
196    /// The graphics root as a project-relative POSIX path (the manifest's
197    /// `gfxRoot`, default `"gfx"`).
198    #[inline]
199    pub fn gfx_root_rel(&self) -> String {
200        self.gfx_root_rel.clone().unwrap_or_else(|| "gfx".to_string())
201    }
202
203    /// Registry of compiled scene JS, keyed by scene name.
204    #[inline]
205    pub fn scripts(&self) -> &ScriptLoader {
206        &self.scripts
207    }
208
209    /// The full DSL compile report.
210    #[inline]
211    pub fn report(&self) -> &CompileReport {
212        &self.report
213    }
214
215    /// Storyline routing table `(map, npc/onEnter) → storyline`, collected
216    /// from `@trigger` declarations across all scenes.
217    #[inline]
218    pub fn routes(&self) -> &[RouteEntry] {
219        &self.report.routes
220    }
221
222    /// Compiled scene name for a `.scene` file stem (e.g. `"main"` →
223    /// `"Main"`).
224    #[inline]
225    pub fn scene_name_for_stem(&self, stem: &str) -> Option<&str> {
226        self.stem_to_name.get(stem).map(String::as_str)
227    }
228
229    /// `.scene` file stem for a compiled scene name.
230    #[inline]
231    pub fn stem_for_scene_name(&self, name: &str) -> Option<&str> {
232        self.name_to_stem.get(name).map(String::as_str)
233    }
234
235    /// The maps directory as a project-relative POSIX path: the `map`
236    /// activity's `mapsDir` (dataRoot-relative), default `maps`.
237    pub fn maps_dir_rel(&self) -> String {
238        let dir = self
239            .manifest
240            .activities
241            .iter()
242            .find(|a| a.kind == "map")
243            .and_then(|a| a.config.get("mapsDir"))
244            .and_then(|v| v.as_str())
245            .unwrap_or(DEFAULT_MAPS_DIR);
246        join_path(&self.data_root_rel, dir)
247    }
248
249    /// Directory holding the per-map subdirectories (disk form of
250    /// [`maps_dir_rel`](Self::maps_dir_rel)).
251    pub fn maps_dir(&self) -> PathBuf {
252        self.root.join(self.maps_dir_rel())
253    }
254
255    /// Sorted ids of all map directories under [`maps_dir`](Self::maps_dir).
256    pub fn map_ids(&self) -> Vec<String> {
257        let prefix = format!("{}/", self.maps_dir_rel());
258        let mut ids: Vec<String> = self
259            .files
260            .list(&self.maps_dir_rel())
261            .iter()
262            .filter_map(|p| {
263                // Only files INSIDE a subdirectory name a map (a direct file
264                // under the maps dir is not a map).
265                let rest = p.strip_prefix(&prefix)?;
266                rest.contains('/').then(|| rest.split('/').next().unwrap().to_string())
267            })
268            .collect();
269        ids.sort();
270        ids.dedup();
271        ids
272    }
273
274    /// Map to spawn on: `game.entryMap`, or the first map directory (sorted)
275    /// under the maps dir.
276    ///
277    /// # Errors
278    ///
279    /// Fails when no `entryMap` is configured and no map directories exist.
280    pub fn entry_map(&self) -> Result<String> {
281        if let Some(entry) = self
282            .manifest
283            .game
284            .as_ref()
285            .and_then(|g| g.entry_map.as_deref())
286        {
287            return Ok(entry.to_string());
288        }
289        self.map_ids().into_iter().next().with_context(|| {
290            format!(
291                "no game.entryMap in the manifest and no maps under {}",
292                self.maps_dir().display()
293            )
294        })
295    }
296
297    /// Scene to boot into: `game.entryScene` (a `.scene` file stem) resolved
298    /// to its compiled scene name, or the first compiled scene sorted by
299    /// source path.
300    ///
301    /// # Errors
302    ///
303    /// Fails when `entryScene` names a stem that compiled to nothing, or
304    /// when the project has no scenes at all.
305    pub fn entry_scene_name(&self) -> Result<&str> {
306        if let Some(stem) = self
307            .manifest
308            .game
309            .as_ref()
310            .and_then(|g| g.entry_scene.as_deref())
311        {
312            return self.scene_name_for_stem(stem).with_context(|| {
313                format!("game.entryScene '{stem}' did not compile to any scene")
314            });
315        }
316        self.report
317            .scenes
318            .iter()
319            .min_by(|a, b| a.2.cmp(&b.2))
320            .map(|(name, _, _)| name.as_str())
321            .context("project compiled no scenes; nothing to boot into")
322    }
323
324    /// Load a map by id from this project's maps dir.
325    pub fn load_map(&self, map_id: &str) -> Result<RuntimeMap> {
326        RuntimeMap::load_with_files(self.files.as_ref(), &self.maps_dir_rel(), map_id)
327    }
328
329    /// Directory holding the records of data table `table_id` (a table id
330    /// from the data activity's `config.tables[]`), resolved against the
331    /// project root. `None` when the id names no declared table.
332    pub fn table_dir(&self, table_id: &str) -> Option<PathBuf> {
333        self.table_dir_rel(table_id).map(|rel| self.root.join(rel))
334    }
335
336    /// The record directory of data table `table_id` as a project-relative
337    /// POSIX path (the VFS form of [`table_dir`](Self::table_dir)). `None`
338    /// when the id names no declared table.
339    pub fn table_dir_rel(&self, table_id: &str) -> Option<String> {
340        self.manifest
341            .data_table(table_id)
342            .map(|t| join_path(&self.data_root_rel, &t.dir))
343    }
344}
345
346/// Compile every DSL file under the manifest's DSL dirs through the VFS:
347/// `list` discovers, `read` loads, `compile_files` compiles in memory.
348/// Source paths are project-relative POSIX paths — the same shape
349/// `compile_dirs` produces for a relative project root, and what the
350/// runtime's scene ↔ map matching expects. Unreadable/non-UTF-8 files
351/// become diagnostics (the same bar `dotzuki check` enforces).
352fn compile_project_dsl(files: &dyn ProjectFiles, manifest: &Manifest) -> CompileReport {
353    let mut dsl_files: Vec<(String, String, String)> = Vec::new();
354    let mut read_errors: Vec<String> = Vec::new();
355    for dir in manifest.dsl_dirs_rel() {
356        for path in files.list(&dir) {
357            // Mirror the disk scanner: skip hidden files/dirs, node_modules
358            // and target anywhere in the path.
359            if path
360                .split('/')
361                .any(|c| c.starts_with('.') || c == "node_modules" || c == "target")
362            {
363                continue;
364            }
365            let ext = path.rsplit('.').next().unwrap_or("");
366            if !DSL_EXTENSIONS.contains(&ext) {
367                continue;
368            }
369            match files.read(&path) {
370                Ok(bytes) => match String::from_utf8(bytes) {
371                    Ok(content) => dsl_files.push((ext.to_string(), path, content)),
372                    Err(_) => read_errors.push(format!("Failed to read {path}: not UTF-8")),
373                },
374                Err(e) => read_errors.push(format!("Failed to read {path}: {e:#}")),
375            }
376        }
377    }
378    let mut report = compile_files(&dsl_files, None);
379    report.diagnostics.extend(read_errors);
380    report
381}
382
383/// `.scene` file stem ↔ compiled scene name indexes for a compile report.
384fn stem_indexes(report: &CompileReport) -> (HashMap<String, String>, HashMap<String, String>) {
385    let mut stem_to_name = HashMap::new();
386    let mut name_to_stem = HashMap::new();
387    for (name, _js, source_path) in &report.scenes {
388        let stem = Path::new(source_path)
389            .file_stem()
390            .map(|s| s.to_string_lossy().into_owned())
391            .unwrap_or_default();
392        stem_to_name.insert(stem.clone(), name.clone());
393        name_to_stem.insert(name.clone(), stem);
394    }
395    (stem_to_name, name_to_stem)
396}