diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index e893105..b2c211e 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -104,8 +104,9 @@ pub fn topic(name: &str) -> Option<&'static str> { MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/ (Prism 实例;非加载器 component 会逐项列进报告,不静默丢) -目标目录缺省 = 新实例目录 `<数据根>/instances/<实例名>`(并登记实例,M216 起不再落共享 - .minecraft 根);`--target` 可钉到别处(例如某个实例的运行目录)。 +目标目录缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`(并登记实例,M216 起 + 不再落共享 .minecraft 根;目录名是包名/`--name` 经 ASCII 归一化后的结果,如 `Better-MC`); + `--target` 可钉到别处(例如某个实例的运行目录)。 两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。 · 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖 · 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」 diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index db5ce86..3bdf2f4 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -157,8 +157,8 @@ pub struct InstallArgs { pub struct PackImportArgs { /// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。 pub path: PathBuf, - /// 目标实例目录(缺省 = 新实例目录 `<数据根>/instances/<实例名>`,M216 前是共享游戏目录 - /// `game_home`)。 + /// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是 + /// 共享游戏目录 `game_home`)。 pub target: Option, /// 实例显示名(缺省 = 包名)。 pub name: Option, diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs index 630f275..66f9975 100644 --- a/crates/cli/src/commands/install/loader.rs +++ b/crates/cli/src/commands/install/loader.rs @@ -85,12 +85,13 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result Result/<原版>.json`)必须进人读与 `--json`。 + vanilla_report = report; } // installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。 @@ -143,6 +147,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result, + vanilla: Option<&InstallReport>, registration: Option<&Registration>, name: &str, processors: Option>, @@ -78,6 +79,9 @@ pub fn build( if let Some(section) = &processors { data["processors"] = processor_json(section); } + if let Some(block) = vanilla_json(vanilla) { + data["vanilla"] = block; + } Report::new( data, @@ -85,6 +89,7 @@ pub fn build( dry_run, plan, report, + vanilla, registration, &samples, name, @@ -115,10 +120,47 @@ fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value { node } +/// `--json` 的 `vanilla` 段:先装原版那一步的告警 + 清单结局(`None` = 这次没先装原版)。 +/// +/// **M216 r2 P2-2**:原版 report 在 `is_complete()` 之后就被丢了 ⇒ 「替换了共享 `.minecraft` +/// 里的原版清单」在加载器安装的 JSON 里没有痕迹。 +fn vanilla_json(vanilla: Option<&InstallReport>) -> Option { + let report = vanilla?; + Some(json!({ + "warnings": report.warnings, + "version_json": report.version_json.as_ref().map(|path| path.display().to_string()), + "manifest": report.manifest.as_ref().map(|write| json!({ + "kind": write.as_str(), + "backup": write.backup().map(|path| path.display().to_string()), + })), + })) +} + +/// 人读行里「先装原版」那一段(**M216 r2 P2-2**,与 [`vanilla_json`] 同源):以前 +/// `human_lines` 只遍历 `plan.warnings()`,原版那份 report 的告警与「清单被替换」都没说。 +fn vanilla_lines(vanilla: Option<&InstallReport>) -> Vec { + let Some(report) = vanilla else { + return Vec::new(); + }; + let mut lines: Vec = report + .warnings + .iter() + .map(|warning| format!("告警(原版): {warning}")) + .collect(); + if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) { + lines.push(format!( + "原版版本清单已替换(旧的那份备份在 {})", + backup.display() + )); + } + lines +} + fn human_lines( dry_run: bool, plan: &LoaderInstallPlan, report: Option<&InstallReport>, + vanilla: Option<&InstallReport>, registration: Option<&Registration>, samples: &[serde_json::Value], name: &str, @@ -181,6 +223,7 @@ fn human_lines( for warning in plan.warnings() { human.push(format!("告警: {warning}")); } + human.extend(vanilla_lines(vanilla)); if let Some(section) = processors { human.extend(processor_lines(section)); } @@ -272,3 +315,85 @@ fn processor_lines(section: &ProcessorSection<'_>) -> Vec { } lines } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use unlucky_core::addon::loader::{ + LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport, + }; + use unlucky_core::game::arguments::Environment; + use unlucky_core::game::install::ManifestWrite; + use unlucky_core::game::layout::GameLayout; + use unlucky_core::game::manifest::VersionJson; + + /// 一份最小的 Fabric 加载器计划(零网络:只算路径,不落盘)。 + fn loader_plan(root: &std::path::Path) -> LoaderInstallPlan { + let layout = GameLayout::new(root); + let release = LoaderRelease { + kind: LoaderKind::Fabric, + mc_version: "1.20.1".to_string(), + loader_version: "0.15.11".to_string(), + version_id: "1.20.1-fabric-0.15.11".to_string(), + version_json: VersionJson::from_json_str( + r#"{"id":"1.20.1-fabric-0.15.11","mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient","inheritsFrom":"1.20.1","libraries":[]}"#, + ) + .expect("loader 清单应可解析"), + support: LoaderSupport::JsonRoute, + installer: None, + install_profile: None, + warnings: Vec::new(), + }; + LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &Environment::host(), + vanilla_installed: true, + }) + } + + /// **M216 r2 P2-2**:装加载器时「先装原版」那一步替换了共享 `.minecraft` 里的原版清单, + /// 这句话必须出现在**加载器安装**的人读输出里(此前只出现在纯原版安装的输出里)。 + /// + /// 抓得住:`human_lines` 只遍历 `plan.warnings()`(= r1 状态);备份路径没渲出来; + /// `--json` 的 `vanilla` 段没了。抓不住:真实 `ull install --loader …` 整条链路(要网络), + /// 以及「原版 report 是否真被 `loader.rs` 传进来」那步接线(见报告)。 + #[test] + fn a_replaced_vanilla_manifest_is_said_out_loud_in_the_loader_output() { + let root = std::env::temp_dir().join(format!("ull216-loader-{}", std::process::id())); + let plan = loader_plan(&root); + let backup = PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json.ullbak-99"); + let vanilla = InstallReport { + files: Vec::new(), + version_json: Some(PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json")), + manifest: Some(ManifestWrite::Replaced { + backup: backup.clone(), + }), + warnings: vec!["已替换版本清单 /g/.minecraft/versions/1.20.1/1.20.1.json".to_string()], + }; + + let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n"); + assert!( + text.contains("已替换版本清单"), + "原版那段的告警没进人读输出:{text}" + ); + assert!( + text.contains(&backup.display().to_string()), + "原版清单的备份路径没进人读输出:{text}" + ); + // 没先装原版时不许凭空多说话。 + let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n"); + assert!(!plain.contains("原版版本清单已替换"), "{plain}"); + + // `--json` 的 `vanilla` 段(结构化,脚本可读)。 + let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段"); + assert_eq!(block["manifest"]["kind"], Value::String("replaced".into())); + assert_eq!( + block["manifest"]["backup"].as_str(), + Some(backup.to_string_lossy().as_ref()) + ); + assert!(vanilla_json(None).is_none()); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 9e96ab0..7bd9020 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -99,7 +99,7 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result { &target, &seen, args, - binding.as_deref(), + binding.as_ref(), )) } @@ -110,7 +110,7 @@ fn outcome( target: &Path, stages: &[&'static str], args: &PackImportArgs, - binding: Option<&str>, + binding: Option<&Result>, ) -> Outcome { let mut data = serde_json::to_value(report).unwrap_or_else(|_| { json!({ @@ -144,8 +144,14 @@ fn outcome( if !stages.is_empty() { human.push(format!("阶段:{}", stages.join(" → "))); } - if let Some(note) = binding { - human.push(note.to_string()); + if let Some(binding) = binding { + // **M216 r2**:`--json` 里给**结构化成败**(r1 只印人读行,脚本看不到成败)。 + if let Some(object) = data.as_object_mut() { + object.insert("instance_bound".to_string(), json!(binding.is_ok())); + } + human.push(match binding { + Ok(note) | Err(note) => note.clone(), + }); } human.push(format!( "结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}", diff --git a/crates/cli/tests/pack_test.rs b/crates/cli/tests/pack_test.rs index 5c77801..2fe6c00 100644 --- a/crates/cli/tests/pack_test.rs +++ b/crates/cli/tests/pack_test.rs @@ -221,6 +221,14 @@ fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() { ]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + // **M216 r2**:绑定成败也进 `--json`(结构化,不必猜文案)。 + let value = parse_json(&stdout); + assert_eq!( + value["data"]["instance_bound"], + Value::Bool(true), + "缺省导入应当把实例绑到内容目录并报 true:{stdout}" + ); + let instance_dir = data.path().join(".ULL/data/instances/My-Pack"); assert!( instance_dir.join("mods/a.jar").is_file(), @@ -266,18 +274,27 @@ fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() { ); } -/// **M216 P2-1**:`--help` 里的缺省落点必须与新行为一致(写旧行为 = 用户照读会以为文件进了 -/// `.minecraft`)。 +/// **M216 P2-1 / r2**:`--help` 里的缺省落点必须是**真路径**。 /// -/// 抓得住:文案退回「共享游戏目录」;新落点没写进去。抓不住:`model.rs` 的字段文档 -/// (那是编译期文档,测试读不到)——所以断言落在这里只覆盖用户能看见的那一份。 +/// 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行), +/// 所以实例目录的真路径是 `<数据根>/.ULL/data/instances/<目录名>`。r1 写成了 +/// `<数据根>/instances/<实例名>` —— 少一层 `.ULL/data`,照读会去一个不存在的位置找包内容。 +/// +/// 抓得住:缺 `.ULL/data` 这一层(下面第二条断言就是抓它的——少写时该子串恰好出现); +/// 文案退回「共享游戏目录」;目录名归一化这件事没提。抓不住:`model.rs` 的字段文档 +/// (编译期文档,测试读不到),以及真实 `--data-dir` 下的绝对路径拼接(那由上面那条端到端 +/// 用例按真目录断言)。 #[test] -fn help_describes_the_new_default_target() { +fn help_describes_the_new_default_target_with_the_real_path() { let (code, stdout, _) = run_cli(&["help", "pack"]); assert_eq!(code, 0); assert!( - stdout.contains("instances/<实例名>"), - "帮助里没写新缺省落点:{stdout}" + stdout.contains("<数据根>/.ULL/data/instances/<实例目录名>"), + "帮助里没写真路径 <数据根>/.ULL/data/instances/<实例目录名>:{stdout}" + ); + assert!( + !stdout.contains("<数据根>/instances/"), + "帮助里的落点少了 `.ULL/data` 这一层(r2 抓的就是这个错法):{stdout}" ); assert!( !stdout.contains("缺省 = 数据根的共享游戏目录"), diff --git a/crates/core/src/game/install/pack_dir.rs b/crates/core/src/game/install/pack_dir.rs index 0ce4887..1d1aa42 100644 --- a/crates/core/src/game/install/pack_dir.rs +++ b/crates/core/src/game/install/pack_dir.rs @@ -132,6 +132,9 @@ pub fn bind_instance_dir( /// 导入收尾:把「我们新建出来的实例」绑到它的内容目录,返回一句给人读的结论。 /// +/// 返回 `None` = 不需要绑;`Some(Ok(说明))` = 已绑;`Some(Err(说明))` = 绑失败(档案在、内容在, +/// 只是运行目录没指过来——调用方**别**把它当整次导入失败,但要让人看见)。 +/// /// 判据(CLI 与 GUI 共用一份,别自己再写一遍): /// - `auto_bound == false` ⇒ 不绑(用户显式给了目标目录 / `--no-register`:那目录可能是别人的 /// 运行目录,也可能被用户自己指过); @@ -146,23 +149,23 @@ pub fn bind_created_instance( target: &Path, auto_bound: bool, report: &crate::addon::import::ImportReport, -) -> Option { +) -> Option> { if !auto_bound { return None; } let registered = report.registered.as_ref().filter(|item| item.created)?; Some( match bind_instance_dir(layout, ®istered.id, ®istered.version_id, target) { - Ok(()) => format!( + Ok(()) => Ok(format!( "运行目录已指向 {}(实例 {})", target.display(), registered.name - ), + )), // 内容与档案都已就位,只是目录没指过来 ⇒ 不把整次导入判失败,但必须说出来。 - Err(error) => format!( + Err(error) => Err(format!( "实例已登记但没能绑到导入目录({error}):启动前请把该实例的运行目录指到 {}", target.display() - ), + )), }, ) } diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 58f75ce..ed87683 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -202,10 +202,14 @@ pub(super) fn run( // **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行 // 目录由全局档派生成 `/versions/`,这批 mods/config 永远不会被启动用到。 // 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。 - if let Some(note) = pack_dir::bind_created_instance(&env.layout, &target, register, &report) { - report + match pack_dir::bind_created_instance(&env.layout, &target, register, &report) { + Some(Ok(note)) => report .warnings - .push(ImportIssue::new("instance_binding", note)); + .push(ImportIssue::new("instance_bound", note)), + Some(Err(note)) => report + .warnings + .push(ImportIssue::new("instance_binding_failed", note)), + None => {} } // 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是 // "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。