dotzuki_runner/
project.rs1use 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
28pub const DEFAULT_MAPS_DIR: &str = "maps";
31
32const MANIFEST_FILE: &str = ".dotzuki-editor.json";
34
35pub struct LoadedProject {
37 files: Arc<dyn ProjectFiles>,
39 root: PathBuf,
40 manifest: Manifest,
41 data_root: PathBuf,
42 data_root_rel: String,
44 gfx_root: Option<PathBuf>,
45 gfx_root_rel: Option<String>,
47 scripts: ScriptLoader,
48 report: CompileReport,
49 stem_to_name: HashMap<String, String>,
51 name_to_stem: HashMap<String, String>,
53}
54
55impl LoadedProject {
56 pub fn load(root: &Path) -> Result<Self> {
64 Self::load_with_files(Arc::new(DiskFiles::new(root)))
65 }
66
67 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 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 #[inline]
163 pub fn files(&self) -> &Arc<dyn ProjectFiles> {
164 &self.files
165 }
166
167 #[inline]
169 pub fn root(&self) -> &Path {
170 &self.root
171 }
172
173 #[inline]
175 pub fn manifest(&self) -> &Manifest {
176 &self.manifest
177 }
178
179 #[inline]
181 pub fn data_root(&self) -> &Path {
182 &self.data_root
183 }
184
185 #[inline]
187 pub fn data_root_rel(&self) -> &str {
188 &self.data_root_rel
189 }
190
191 #[inline]
193 pub fn gfx_root(&self) -> Option<&Path> {
194 self.gfx_root.as_deref()
195 }
196
197 #[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 #[inline]
208 pub fn scripts(&self) -> &ScriptLoader {
209 &self.scripts
210 }
211
212 #[inline]
214 pub fn report(&self) -> &CompileReport {
215 &self.report
216 }
217
218 #[inline]
221 pub fn routes(&self) -> &[RouteEntry] {
222 &self.report.routes
223 }
224
225 #[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 #[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 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 pub fn maps_dir(&self) -> PathBuf {
255 self.root.join(self.maps_dir_rel())
256 }
257
258 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 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 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 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 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 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 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
350fn 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 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
387fn 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}