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 = 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 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 #[inline]
162 pub fn files(&self) -> &Arc<dyn ProjectFiles> {
163 &self.files
164 }
165
166 #[inline]
168 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 #[inline]
174 pub fn manifest(&self) -> &Manifest {
175 &self.manifest
176 }
177
178 #[inline]
180 pub fn data_root(&self) -> &Path {
181 &self.data_root
182 }
183
184 #[inline]
186 pub fn data_root_rel(&self) -> &str {
187 &self.data_root_rel
188 }
189
190 #[inline]
192 pub fn gfx_root(&self) -> Option<&Path> {
193 self.gfx_root.as_deref()
194 }
195
196 #[inline]
199 pub fn gfx_root_rel(&self) -> String {
200 self.gfx_root_rel.clone().unwrap_or_else(|| "gfx".to_string())
201 }
202
203 #[inline]
205 pub fn scripts(&self) -> &ScriptLoader {
206 &self.scripts
207 }
208
209 #[inline]
211 pub fn report(&self) -> &CompileReport {
212 &self.report
213 }
214
215 #[inline]
218 pub fn routes(&self) -> &[RouteEntry] {
219 &self.report.routes
220 }
221
222 #[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 #[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 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 pub fn maps_dir(&self) -> PathBuf {
252 self.root.join(self.maps_dir_rel())
253 }
254
255 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 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 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 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 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 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 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
346fn 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 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
383fn 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}