diff --git a/crates/horizon-prepare/src/lib.rs b/crates/horizon-prepare/src/lib.rs index 0a1009091a..be88e17df0 100644 --- a/crates/horizon-prepare/src/lib.rs +++ b/crates/horizon-prepare/src/lib.rs @@ -34,6 +34,12 @@ use std::path::{Path, PathBuf}; /// Where progress lines go: the terminal for the binary, the log pane for the launcher. pub type Log<'a> = &'a dyn Fn(&str); +/// Where the fraction done goes (`--progress json`), from 0 to 1. +pub type Progress<'a> = &'a dyn Fn(f32); + +/// The stages of `steps`, each opened by `Run::stage`: what the fraction counts. +const STAGES: u32 = 18; + /// The manifest's `schema`: the prepared files' layout, which the launcher compares. pub const SCHEMA: i64 = 7; @@ -324,11 +330,21 @@ struct Run<'a> { previous: Value, origin: Value, log: Log<'a>, + progress: Progress<'a>, + /// The stages of `steps` begun so far. + stages: std::cell::Cell, } const PARITY: &str = "mods/horizon/src/test/java/dev/horizonlab/parity"; impl Run<'_> { + /// Opens the next stage of `steps`: the fraction done is the stages before it. + fn stage(&self) { + let done = self.stages.get(); + (self.progress)(done.min(STAGES) as f32 / STAGES as f32); + self.stages.set(done + 1); + } + fn bundle(&self) -> Result<&Path, String> { if self.bundles.get().is_none() { self.bundles.set(exporters::classes(self)?).unwrap(); @@ -432,15 +448,29 @@ impl Run<'_> { /// Prepares the resources; returns their folder (`resources_dir`). pub fn prepare(options: &Options, log: Log) -> Result { - prepare_impl(options, log, false) + prepare_impl(options, log, &|_| {}, false) +} + +/// `prepare`, telling `progress` the fraction done as each stage begins, and 1 at the end. +pub fn prepare_with_progress( + options: &Options, + log: Log, + progress: Progress, +) -> Result { + prepare_impl(options, log, progress, false) } /// Release build tool: compile and link-check only Horizon's original exporter classes. pub fn compile_exporters(options: &Options, log: Log) -> Result { - prepare_impl(options, log, true) + prepare_impl(options, log, &|_| {}, true) } -fn prepare_impl(options: &Options, log: Log, compile_only: bool) -> Result { +fn prepare_impl( + options: &Options, + log: Log, + progress: Progress, + compile_only: bool, +) -> Result { let root = &std::path::absolute(&options.root) .map_err(|e| format!("{}: {e}", options.root.display()))?; let lock = artifacts::Lock::read(root)?; @@ -517,12 +547,15 @@ fn prepare_impl(options: &Options, log: Log, compile_only: bool) -> Result>()?, )) }; + run.stage(); let mut manifest = vec![("schema".to_string(), Value::Int(SCHEMA.to_string()))]; manifest.extend(run.origin.as_object().unwrap().iter().cloned()); manifest.extend([ @@ -1293,6 +1344,15 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + /// `--progress json` reaches 1 only at the end: `STAGES` counts the stages `steps` opens. + #[test] + fn steps_open_every_stage() { + let source = include_str!("lib.rs"); + let body = &source[source.find("\nfn steps(").unwrap()..]; + let body = &body[..body.find("\n}\n").unwrap()]; + assert_eq!(body.matches("run.stage();").count() as u32, STAGES); + } + #[test] fn output_versions_end_at_the_output_version() { let text = include_str!("../output-versions.txt"); diff --git a/crates/horizon-prepare/src/main.rs b/crates/horizon-prepare/src/main.rs index fd9da3a0e0..2872711e82 100644 --- a/crates/horizon-prepare/src/main.rs +++ b/crates/horizon-prepare/src/main.rs @@ -16,6 +16,7 @@ const HELP: &str = "\ horizon-prepare: prepare Horizon's resources from your own Minecraft Java Edition Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [--root PATH] [--output PATH] + [--complete] [--progress json] horizon-prepare --check [--root PATH] [--output PATH] horizon-prepare compile-exporters [--root PATH] horizon-prepare sounds [--minecraft-dir PATH] [--root PATH] [--output PATH] @@ -35,6 +36,10 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [- the exporters' scratch files stay in /.local --check Exit 0 when the resources are complete and current for this preparer, else 1, printing what each gap needs; runs no Java and reads no game files + --complete Also copy the sounds, then fail unless --check would pass: one command + for resources the game plays with nothing missing + --progress json One JSON object a line on stdout: {\"message\": ...} for each log line, + {\"fraction\": F} as the work advances (0 to 1), {\"error\": ...} on failure Needs Minecraft played once from the official launcher, and Java of the version research-lock.json pins. Releases use a runtime; development or changed exporters need a JDK. @@ -80,6 +85,8 @@ fn main() -> ExitCode { }; let mut root = None; let mut check = false; + let mut complete = false; + let mut json = false; let mut args = std::env::args_os().skip(1).peekable(); let assets = args.next_if(|a| { matches!( @@ -120,6 +127,18 @@ fn main() -> ExitCode { check = true; Ok(()) } + "--complete" if inline.is_none() && assets.is_none() => { + complete = true; + Ok(()) + } + "--progress" => match value("--progress") { + Ok(v) if v.as_os_str() == "json" => { + json = true; + Ok(()) + } + Ok(v) => Err(format!("--progress takes json, not {}", v.display())), + Err(e) => Err(e), + }, "-h" | "--help" => { print!("{HELP}"); return ExitCode::SUCCESS; @@ -149,11 +168,22 @@ fn main() -> ExitCode { } return check_resources(&options); } - let log = |line: &str| { + let print = |line: &str| { let mut out = std::io::stdout().lock(); let _ = writeln!(out, "{line}"); let _ = out.flush(); }; + let log = |line: &str| match json { + true => print(&json_line("message", line)), + false => print(line), + }; + // Preparing is most of a complete run; the sounds and the check are the rest. + let scale = if complete { 0.9 } else { 1.0 }; + let progress = |fraction: f32| { + if json { + print(&format!("{{\"fraction\": {:.3}}}", fraction * scale)); + } + }; let result = match assets.as_deref().and_then(|a| a.to_str()) { Some("compile-exporters") => horizon_prepare::compile_exporters(&options, &log) .inspect(|dir| log(&dir.display().to_string())), @@ -166,17 +196,65 @@ fn main() -> ExitCode { &log, ) } - _ => horizon_prepare::prepare(&options, &log), + _ => horizon_prepare::prepare_with_progress(&options, &log, &progress).and_then( + |resources| match complete { + true => complete_resources(&options, resources, &log), + false => Ok(resources), + }, + ), }; match result { - Ok(_) => ExitCode::SUCCESS, + Ok(_) => { + // A plain preparation reported its 1 already. + if json && (complete || assets.is_some()) { + print("{\"fraction\": 1.000}"); + } + ExitCode::SUCCESS + } Err(e) => { + if json { + print(&json_line("error", &e)); + } eprintln!("error: {e}"); ExitCode::FAILURE } } } +/// `--complete` after preparing: the sounds, then nothing may be missing. +fn complete_resources( + options: &horizon_prepare::Options, + resources: PathBuf, + log: horizon_prepare::Log, +) -> Result { + horizon_prepare::add_assets( + "sounds", + &options.root, + Some(&resources), + options.minecraft_dir.as_deref(), + log, + )?; + let lacking = horizon_prepare::check(&options.root, &resources)?; + if lacking.is_empty() { + return Ok(resources); + } + for line in &lacking { + log(line); + } + Err(format!( + "the resources are not complete: {}", + lacking.join("; ") + )) +} + +/// `{"key": "text"}` on one line. +fn json_line(key: &str, text: &str) -> String { + let mut out = format!("{{\"{key}\": "); + horizon_prepare::pyjson::write_string(&mut out, text); + out.push('}'); + out +} + /// `--check`: each gap on its own line on stdout, exit 1 when there is one. fn check_resources(options: &horizon_prepare::Options) -> ExitCode { let resources = horizon_prepare::artifacts::Lock::read(&options.root).map(|lock| { diff --git a/docs/native-client.md b/docs/native-client.md index ffaa5bdb77..ba62ff7536 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -41,8 +41,11 @@ exits 0 when the resources are complete and current for this preparer, and other each gap with the command that fills it and exits 1; it runs no Java and reads no game files. A preparation stamps its resources with the preparer's `OUTPUT_VERSION` (`horizon-prepare.json`); resources prepared before the stamp existed report an unknown -version until they are prepared again. Exit codes: 0 done, 1 failed or gaps found, 2 a refused -command line. +version until they are prepared again. `--complete` prepares, copies the sounds, and fails +unless `--check` would then pass: one command for resources with nothing missing. +`--progress json` prints one JSON object a line instead of plain text: `{"message": …}` for +each log line, `{"fraction": F}` (0 to 1) as each stage begins, `{"error": …}` on failure. +Exit codes: 0 done, 1 failed or gaps found, 2 a refused command line. ```sh cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar