use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use dotzuki_engine_dsl::compiler::{compile_files, CompileReport, RouteEntry, DSL_EXTENSIONS};
use dotzuki_engine_dsl::loader::register_compiled;
use dotzuki_engine_script::loader::ScriptLoader;
use crate::manifest::Manifest;
use crate::map::RuntimeMap;
use crate::vfs::{join_path, DiskFiles, ProjectFiles};
pub const DEFAULT_MAPS_DIR: &str = "maps";
const MANIFEST_FILE: &str = ".dotzuki-editor.json";
pub struct LoadedProject {
files: Arc<dyn ProjectFiles>,
root: PathBuf,
manifest: Manifest,
data_root: PathBuf,
data_root_rel: String,
gfx_root: Option<PathBuf>,
gfx_root_rel: Option<String>,
scripts: ScriptLoader,
report: CompileReport,
stem_to_name: HashMap<String, String>,
name_to_stem: HashMap<String, String>,
}
impl LoadedProject {
pub fn load(root: &Path) -> Result<Self> {
Self::load_with_files(Arc::new(DiskFiles::new(root)))
}
pub fn load_with_files(files: Arc<dyn ProjectFiles>) -> Result<Self> {
let root: PathBuf = files.root().map(Path::to_path_buf).unwrap_or_default();
let bytes = files.read(MANIFEST_FILE).map_err(|_| {
anyhow::anyhow!(
"no {MANIFEST_FILE} found in {} — not a jrpg game project",
if root.as_os_str().is_empty() {
"<memory>".to_string()
} else {
root.display().to_string()
}
)
})?;
let text = String::from_utf8(bytes).with_context(|| format!("{MANIFEST_FILE} is not UTF-8"))?;
let manifest: Manifest = serde_json::from_str(&text)
.with_context(|| format!("failed to parse {MANIFEST_FILE}"))?;
let data_root_rel = join_path("", &manifest.data_root);
let gfx_root_rel = manifest.gfx_root.as_deref().map(|g| join_path("", g));
let data_root = root.join(&data_root_rel);
let gfx_root = gfx_root_rel.as_ref().map(|g| root.join(g));
let report = compile_project_dsl(files.as_ref(), &manifest);
if !report.diagnostics.is_empty() {
bail!(
"DSL compile failed with {} diagnostic(s):\n {}",
report.diagnostics.len(),
report.diagnostics.join("\n ")
);
}
let mut scripts = ScriptLoader::new();
register_compiled(&mut scripts, &report);
let (stem_to_name, name_to_stem) = stem_indexes(&report);
Ok(Self {
files,
root,
manifest,
data_root,
data_root_rel,
gfx_root,
gfx_root_rel,
scripts,
report,
stem_to_name,
name_to_stem,
})
}
pub fn recompile_scripts(&mut self) -> Result<()> {
let report = compile_project_dsl(self.files.as_ref(), &self.manifest);
if !report.diagnostics.is_empty() {
bail!(
"DSL recompile failed with {} diagnostic(s):\n {}",
report.diagnostics.len(),
report.diagnostics.join("\n ")
);
}
let mut scripts = ScriptLoader::new();
register_compiled(&mut scripts, &report);
let (stem_to_name, name_to_stem) = stem_indexes(&report);
self.scripts = scripts;
self.report = report;
self.stem_to_name = stem_to_name;
self.name_to_stem = name_to_stem;
Ok(())
}
#[inline]
pub fn files(&self) -> &Arc<dyn ProjectFiles> {
&self.files
}
#[inline]
pub fn root(&self) -> &Path {
&self.root
}
#[inline]
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
#[inline]
pub fn data_root(&self) -> &Path {
&self.data_root
}
#[inline]
pub fn data_root_rel(&self) -> &str {
&self.data_root_rel
}
#[inline]
pub fn gfx_root(&self) -> Option<&Path> {
self.gfx_root.as_deref()
}
#[inline]
pub fn gfx_root_rel(&self) -> String {
self.gfx_root_rel.clone().unwrap_or_else(|| "gfx".to_string())
}
#[inline]
pub fn scripts(&self) -> &ScriptLoader {
&self.scripts
}
#[inline]
pub fn report(&self) -> &CompileReport {
&self.report
}
#[inline]
pub fn routes(&self) -> &[RouteEntry] {
&self.report.routes
}
#[inline]
pub fn scene_name_for_stem(&self, stem: &str) -> Option<&str> {
self.stem_to_name.get(stem).map(String::as_str)
}
#[inline]
pub fn stem_for_scene_name(&self, name: &str) -> Option<&str> {
self.name_to_stem.get(name).map(String::as_str)
}
pub fn maps_dir_rel(&self) -> String {
let dir = self
.manifest
.activities
.iter()
.find(|a| a.kind == "map")
.and_then(|a| a.config.get("mapsDir"))
.and_then(|v| v.as_str())
.unwrap_or(DEFAULT_MAPS_DIR);
join_path(&self.data_root_rel, dir)
}
pub fn maps_dir(&self) -> PathBuf {
self.root.join(self.maps_dir_rel())
}
pub fn map_ids(&self) -> Vec<String> {
let prefix = format!("{}/", self.maps_dir_rel());
let mut ids: Vec<String> = self
.files
.list(&self.maps_dir_rel())
.iter()
.filter_map(|p| {
let rest = p.strip_prefix(&prefix)?;
rest.contains('/').then(|| rest.split('/').next().unwrap().to_string())
})
.collect();
ids.sort();
ids.dedup();
ids
}
pub fn entry_map(&self) -> Result<String> {
if let Some(entry) = self
.manifest
.game
.as_ref()
.and_then(|g| g.entry_map.as_deref())
{
return Ok(entry.to_string());
}
self.map_ids().into_iter().next().with_context(|| {
format!(
"no game.entryMap in the manifest and no maps under {}",
self.maps_dir().display()
)
})
}
pub fn entry_scene_name(&self) -> Result<&str> {
if let Some(stem) = self
.manifest
.game
.as_ref()
.and_then(|g| g.entry_scene.as_deref())
{
return self.scene_name_for_stem(stem).with_context(|| {
format!("game.entryScene '{stem}' did not compile to any scene")
});
}
self.report
.scenes
.iter()
.min_by(|a, b| a.2.cmp(&b.2))
.map(|(name, _, _)| name.as_str())
.context("project compiled no scenes; nothing to boot into")
}
pub fn load_map(&self, map_id: &str) -> Result<RuntimeMap> {
RuntimeMap::load_with_files(self.files.as_ref(), &self.maps_dir_rel(), map_id)
}
pub fn table_dir(&self, table_id: &str) -> Option<PathBuf> {
self.table_dir_rel(table_id).map(|rel| self.root.join(rel))
}
pub fn table_dir_rel(&self, table_id: &str) -> Option<String> {
self.manifest
.data_table(table_id)
.map(|t| join_path(&self.data_root_rel, &t.dir))
}
}
fn compile_project_dsl(files: &dyn ProjectFiles, manifest: &Manifest) -> CompileReport {
let mut dsl_files: Vec<(String, String, String)> = Vec::new();
let mut read_errors: Vec<String> = Vec::new();
for dir in manifest.dsl_dirs_rel() {
for path in files.list(&dir) {
if path
.split('/')
.any(|c| c.starts_with('.') || c == "node_modules" || c == "target")
{
continue;
}
let ext = path.rsplit('.').next().unwrap_or("");
if !DSL_EXTENSIONS.contains(&ext) {
continue;
}
match files.read(&path) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(content) => dsl_files.push((ext.to_string(), path, content)),
Err(_) => read_errors.push(format!("Failed to read {path}: not UTF-8")),
},
Err(e) => read_errors.push(format!("Failed to read {path}: {e:#}")),
}
}
}
let mut report = compile_files(&dsl_files, None);
report.diagnostics.extend(read_errors);
report
}
fn stem_indexes(report: &CompileReport) -> (HashMap<String, String>, HashMap<String, String>) {
let mut stem_to_name = HashMap::new();
let mut name_to_stem = HashMap::new();
for (name, _js, source_path) in &report.scenes {
let stem = Path::new(source_path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
stem_to_name.insert(stem.clone(), name.clone());
name_to_stem.insert(name.clone(), stem);
}
(stem_to_name, name_to_stem)
}