use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::{bundle, check, runner_pkg};
pub struct ExportArgs {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub runner_pkg: Option<PathBuf>,
pub rebuild_runner: bool,
pub force: bool,
pub save_key: Option<String>,
pub lang: String,
}
const INDEX_TEMPLATE: &str = include_str!("../templates/web-player.html");
pub fn run(args: &ExportArgs) -> Result<PathBuf> {
let diags = gate_diagnostics(&args.dir, args.force)?;
let files = bundle::collect_project_files(&args.dir).context("failed to collect project files")?;
let pkg = runner_pkg::locate(args.runner_pkg.as_deref(), args.rebuild_runner)?;
let out = args
.out
.clone()
.unwrap_or_else(|| args.dir.join("dist").join("web"));
write_site(
&out,
&diags.manifest.name,
args.save_key.as_deref(),
&args.lang,
&files,
&pkg,
)?;
let bundle_bytes = fs::metadata(out.join(bundle::PACK_FILE)).map(|m| m.len()).unwrap_or(0);
println!(
"exported {} file(s) ({:.1} MiB pack) to {}",
files.len(),
bundle_bytes as f64 / (1024.0 * 1024.0),
out.display()
);
println!("serve it with any static file server, e.g.:");
println!(" python3 -m http.server --directory {}", out.display());
Ok(out)
}
pub fn gate_diagnostics(dir: &Path, force: bool) -> Result<check::ProjectDiagnostics> {
let diags = check::diagnose(dir)?;
if diags.total() > 0 {
for d in diags.all() {
eprintln!("error: {d}");
}
if !force {
bail!(
"export aborted: {} diagnostic(s) — fix them (see `dotzuki check`) or pass --force",
diags.total()
);
}
eprintln!(
"warning: exporting despite {} diagnostic(s) (--force)",
diags.total()
);
}
Ok(diags)
}
fn write_site(
out: &Path,
title: &str,
save_key: Option<&str>,
lang: &str,
files: &BTreeMap<String, Vec<u8>>,
pkg: &Path,
) -> Result<()> {
let wasm_dir = out.join("wasm");
fs::create_dir_all(&wasm_dir)
.with_context(|| format!("failed to create {}", wasm_dir.display()))?;
for file in [runner_pkg::JS_FILE, runner_pkg::WASM_FILE] {
fs::copy(pkg.join(file), wasm_dir.join(file))
.with_context(|| format!("failed to copy {file} from {}", pkg.display()))?;
}
fs::write(out.join(bundle::PACK_FILE), bundle::serialize_pack(files))
.context("failed to write game.dzpk")?;
let key = save_key
.map(str::to_string)
.unwrap_or_else(|| default_save_key(title));
let html = render_index_html(title, &key, lang);
fs::write(out.join("index.html"), html).context("failed to write index.html")?;
Ok(())
}
fn default_save_key(title: &str) -> String {
format!("dotzuki-save:{title}")
}
struct PageStrings {
loading: &'static str,
loading_runtime: &'static str,
downloading: &'static str,
starting: &'static str,
failed_prefix: &'static str,
hint: &'static str,
}
fn page_strings(lang: &str) -> PageStrings {
match lang {
"zh" => PageStrings {
loading: "加载中…",
loading_runtime: "加载运行时…",
downloading: "下载游戏包…",
starting: "启动中…",
failed_prefix: "加载失败: ",
hint: "方向键 / WASD — 移动 · Z — A · X — B · Enter — Start · Backspace — Select · M — 静音",
},
_ => PageStrings {
loading: "Loading…",
loading_runtime: "Loading runtime…",
downloading: "Downloading game…",
starting: "Starting…",
failed_prefix: "Failed to load: ",
hint: "Arrows / WASD — move · Z — A · X — B · Enter — Start · Backspace — Select · M — mute",
},
}
}
fn render_index_html(title: &str, save_key: &str, lang: &str) -> String {
let escaped = html_escape(title);
let key_json = serde_json::to_string(save_key).unwrap_or_else(|_| "\"dotzuki-save\"".into());
let s = page_strings(lang);
let js = |v: &str| serde_json::to_string(v).unwrap();
INDEX_TEMPLATE
.replace("__DOTZUKI_TITLE__", &escaped)
.replace("__DOTZUKI_SAVE_KEY__", &key_json)
.replace("__DOTZUKI_STR_LOADING__", s.loading)
.replace("__DOTZUKI_STR_LOADING_RUNTIME__", &js(s.loading_runtime))
.replace("__DOTZUKI_STR_DOWNLOADING__", &js(s.downloading))
.replace("__DOTZUKI_STR_STARTING__", &js(s.starting))
.replace("__DOTZUKI_STR_FAILED_PREFIX__", &js(s.failed_prefix))
.replace("__DOTZUKI_STR_HINT__", s.hint)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use dotzuki_runner::vfs::ProjectFiles as _;
use dotzuki_runner::{run_headless, HeadlessOptions, LoadedProject, RunnerGame, RunnerOptions};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(test: &str) -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"dotzuki-cli-export-{test}-{}-{id}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn fake_runner_pkg(test: &str) -> TestDir {
let tmp = TestDir::new(test);
fs::write(tmp.0.join(runner_pkg::JS_FILE), "// test stub").unwrap();
fs::write(tmp.0.join(runner_pkg::WASM_FILE), b"\0asm").unwrap();
tmp
}
fn example_project() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/your-first-game");
dir.is_dir().then_some(dir)
}
fn args(dir: PathBuf, out: PathBuf, pkg: &Path, force: bool) -> ExportArgs {
ExportArgs {
dir,
out: Some(out),
runner_pkg: Some(pkg.to_path_buf()),
rebuild_runner: false,
force,
save_key: None,
lang: "en".to_string(),
}
}
#[test]
fn export_produces_a_bootable_site() {
let Some(project) = example_project() else {
eprintln!("skipping: examples/your-first-game not present");
return;
};
let pkg = fake_runner_pkg("site-pkg");
let tmp = TestDir::new("site");
let out = export_dir(&tmp);
let produced = run(&args(project, out.clone(), &pkg.0, false)).unwrap();
assert_eq!(produced, out);
let html = fs::read_to_string(out.join("index.html")).unwrap();
let pack_bytes = fs::read(out.join(bundle::PACK_FILE)).unwrap();
assert!(out.join("wasm").join(runner_pkg::JS_FILE).is_file());
assert!(out.join("wasm").join(runner_pkg::WASM_FILE).is_file());
assert!(html.contains("Your First Game"), "{html}");
assert!(html.contains("dotzuki-save:Your First Game"), "{html}");
let pack = dotzuki_runner::pack::PackFiles::from_bytes(pack_bytes).unwrap();
assert!(pack.exists(".dotzuki-editor.json"));
let project = LoadedProject::load_with_files(Arc::new(pack)).unwrap();
let mut game = RunnerGame::new(
project,
RunnerOptions {
headless: true,
pcm_audio: true,
fresh: true,
..RunnerOptions::default()
},
)
.unwrap();
run_headless(&mut game, &HeadlessOptions {
frames: 30,
..HeadlessOptions::default()
})
.unwrap();
}
#[test]
fn save_key_and_lang_customize_the_player_page() {
let Some(project) = example_project() else {
eprintln!("skipping: examples/your-first-game not present");
return;
};
let pkg = fake_runner_pkg("custom-pkg");
let tmp = TestDir::new("custom");
let mut a = args(project, export_dir(&tmp), &pkg.0, false);
a.save_key = Some("dotzuki-cloud-save:my-game".to_string());
a.lang = "zh".to_string();
let out = run(&a).unwrap();
let html = fs::read_to_string(out.join("index.html")).unwrap();
assert!(html.contains("\"dotzuki-cloud-save:my-game\""), "{html}");
assert!(html.contains("加载中…"), "{html}");
assert!(html.contains("加载失败"), "{html}");
assert!(html.contains("静音"), "{html}");
assert!(!html.contains("__DOTZUKI_"), "unreplaced placeholder: {html}");
}
#[test]
fn default_page_is_english_with_the_title_save_key() {
let Some(project) = example_project() else {
eprintln!("skipping: examples/your-first-game not present");
return;
};
let pkg = fake_runner_pkg("default-pkg");
let tmp = TestDir::new("default");
let out = run(&args(project, export_dir(&tmp), &pkg.0, false)).unwrap();
let html = fs::read_to_string(out.join("index.html")).unwrap();
assert!(html.contains("Loading…"), "{html}");
assert!(html.contains("\"dotzuki-save:Your First Game\""), "{html}");
assert!(!html.contains("__DOTZUKI_"), "unreplaced placeholder: {html}");
}
#[test]
fn dsl_errors_block_export_unless_forced() {
let tmp = TestDir::new("broken");
let root = tmp.0.join("proj");
fs::create_dir_all(root.join("assets/scenes")).unwrap();
fs::write(
root.join(".dotzuki-editor.json"),
r#"{ "name": "broken", "dataRoot": "./data" }"#,
)
.unwrap();
fs::write(root.join("assets/scenes/bad.scene"), "this is not a scene {{{").unwrap();
let pkg = fake_runner_pkg("broken-pkg");
let err = run(&args(root.clone(), export_dir(&tmp), &pkg.0, false)).unwrap_err();
assert!(err.to_string().contains("--force"), "{err}");
let out = run(&args(root, export_dir(&tmp), &pkg.0, true)).unwrap();
assert!(out.join(bundle::PACK_FILE).is_file());
}
#[test]
fn missing_project_is_an_error() {
let tmp = TestDir::new("missing");
let pkg = fake_runner_pkg("missing-pkg");
let err = run(&args(tmp.0.join("nope"), export_dir(&tmp), &pkg.0, false)).unwrap_err();
assert!(err.to_string().contains(".dotzuki-editor.json"), "{err}");
}
fn export_dir(tmp: &TestDir) -> PathBuf {
tmp.0.join(format!("out-{}", NEXT_ID.fetch_add(1, Ordering::SeqCst)))
}
}