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