CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
346 lines
12 KiB
Rust
346 lines
12 KiB
Rust
//! D20 隔离档位的 CLI 端到端:`ull launch` 按覆盖链把派生结果填进 `--gameDir`。
|
||
//!
|
||
//! 四条路径(全局 isolated / 全局 shared / 实例 override / custom)+ 旧档案读兼容 +
|
||
//! `--dry-run` 不落盘。断言走 `--json` 输出的 `isolation` / `isolation_source` / `game_dir`,
|
||
//! 也就是真正喂给 `${game_directory}` 与子进程 CWD 的那个值。
|
||
|
||
#[path = "support/mod.rs"]
|
||
mod support;
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use support::{parse_json, run_cli, TempDir};
|
||
|
||
/// 实例 id(与 `profiles.json` 里写的必须一致)。
|
||
const INSTANCE_ID: &str = "11111111-2222-4333-8444-000000000001";
|
||
/// 版本 id。
|
||
const VERSION: &str = "1.20.1";
|
||
|
||
/// 建数据根:版本本体 + `profiles.json`(一个实例「生存服」)。
|
||
fn data_root(tag: &str) -> TempDir {
|
||
let dir = TempDir::new(tag);
|
||
write_version_with_game_dir(dir.path());
|
||
write_profiles(dir.path(), None);
|
||
dir
|
||
}
|
||
|
||
/// 写一份「带 `--gameDir ${game_directory}`」的最小版本 JSON + 空 client jar。
|
||
///
|
||
/// 比 `support::write_version` 多这一个参数——`--gameDir` 就是隔离档位的真实落点
|
||
/// (正式版清单 1.13+ 都有这条),没有它就无法从命令行验证派生结果。
|
||
fn write_version_with_game_dir(root: &Path) {
|
||
let dir = root.join(".minecraft").join("versions").join(VERSION);
|
||
std::fs::create_dir_all(&dir).expect("建版本目录");
|
||
let version = serde_json::json!({
|
||
"id": VERSION,
|
||
"type": "release",
|
||
"mainClass": "net.minecraft.client.main.Main",
|
||
"assets": "5",
|
||
"assetIndex": { "id": "5" },
|
||
"arguments": {
|
||
"game": [
|
||
"--username", "${auth_player_name}",
|
||
"--version", "${version_name}",
|
||
"--gameDir", "${game_directory}"
|
||
],
|
||
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
|
||
},
|
||
"libraries": [],
|
||
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
|
||
});
|
||
std::fs::write(
|
||
dir.join(format!("{VERSION}.json")),
|
||
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
|
||
)
|
||
.expect("写版本 JSON");
|
||
std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar");
|
||
}
|
||
|
||
/// 写 `config/profiles.json`(可选 `game_dir` 遗留字段)。
|
||
fn write_profiles(root: &Path, legacy_game_dir: Option<&Path>) {
|
||
let profiles_dir = root.join(".ULL").join("config");
|
||
std::fs::create_dir_all(&profiles_dir).expect("建配置目录");
|
||
let mut profile = serde_json::json!({
|
||
"id": INSTANCE_ID,
|
||
"name": "生存服",
|
||
"version_id": VERSION,
|
||
});
|
||
if let Some(game_dir) = legacy_game_dir {
|
||
profile["game_dir"] = serde_json::json!(game_dir.display().to_string());
|
||
}
|
||
let document = serde_json::json!({
|
||
"schema_version": 1,
|
||
"profiles": [profile],
|
||
});
|
||
std::fs::write(
|
||
profiles_dir.join("profiles.json"),
|
||
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
|
||
)
|
||
.expect("写 profiles.json");
|
||
}
|
||
|
||
/// 写实例设置文件(`config/instances/<id>.json`)。
|
||
fn write_instance_config(dir: &Path, value: &serde_json::Value) {
|
||
let path = dir
|
||
.join(".ULL")
|
||
.join("config")
|
||
.join("instances")
|
||
.join(format!("{INSTANCE_ID}.json"));
|
||
std::fs::create_dir_all(path.parent().expect("父目录")).expect("建实例配置目录");
|
||
std::fs::write(
|
||
&path,
|
||
serde_json::to_vec_pretty(value).expect("序列化实例设置"),
|
||
)
|
||
.expect("写实例设置");
|
||
}
|
||
|
||
/// 跑一次 `launch <instance> --dry-run --json`。
|
||
fn dry_run(dir: &TempDir) -> serde_json::Value {
|
||
let (code, stdout, stderr) = run_cli(&[
|
||
"launch",
|
||
"生存服",
|
||
"--dry-run",
|
||
"--json",
|
||
"--data-dir",
|
||
&dir.arg(),
|
||
]);
|
||
assert_eq!(code, 0, "{stderr}");
|
||
parse_json(&stdout)["data"].clone()
|
||
}
|
||
|
||
/// 数据根下的游戏目录。
|
||
fn game_root(dir: &TempDir) -> PathBuf {
|
||
dir.path().join(".minecraft")
|
||
}
|
||
|
||
#[test]
|
||
fn default_mode_is_isolated_and_derives_version_dir() {
|
||
let dir = data_root("iso-default");
|
||
let data = dry_run(&dir);
|
||
assert_eq!(data["isolation"], "isolated", "D20 默认隔离");
|
||
assert_eq!(data["isolation_source"], "global");
|
||
assert_eq!(data["instance_found"], true, "实例名解析仍然有效");
|
||
assert_eq!(
|
||
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
|
||
game_root(&dir).join("versions").join(VERSION)
|
||
);
|
||
assert!(data["mode_change_warning"].is_null());
|
||
|
||
// 端到端:派生值必须是命令行里 `--gameDir` 的实参(占位符已替换、家目录已脱敏,
|
||
// 所以按路径后缀断言)。
|
||
let argv: Vec<&str> = data["argv"]
|
||
.as_array()
|
||
.expect("argv")
|
||
.iter()
|
||
.map(|token| token.as_str().expect("token"))
|
||
.collect();
|
||
let index = argv
|
||
.iter()
|
||
.position(|token| *token == "--gameDir")
|
||
.unwrap_or_else(|| panic!("命令行里没有 --gameDir:{argv:?}"));
|
||
let passed = PathBuf::from(argv[index + 1]);
|
||
assert!(
|
||
passed.ends_with(Path::new("versions").join(VERSION)),
|
||
"--gameDir 不是派生的隔离目录:{}",
|
||
passed.display()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_mode_switches_game_dir_to_game_root() {
|
||
let dir = data_root("iso-shared");
|
||
let (code, _, stderr) = run_cli(&[
|
||
"config",
|
||
"set",
|
||
"instances.mode",
|
||
"shared",
|
||
"--data-dir",
|
||
&dir.arg(),
|
||
]);
|
||
assert_eq!(code, 0, "{stderr}");
|
||
|
||
let data = dry_run(&dir);
|
||
assert_eq!(data["isolation"], "shared");
|
||
assert_eq!(data["isolation_source"], "global");
|
||
assert_eq!(
|
||
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
|
||
game_root(&dir),
|
||
"共享档的运行目录就是 .minecraft 根"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn instance_isolation_overrides_global_mode() {
|
||
let dir = data_root("iso-instance");
|
||
let (code, _, stderr) = run_cli(&[
|
||
"config",
|
||
"set",
|
||
"instances.mode",
|
||
"shared",
|
||
"--data-dir",
|
||
&dir.arg(),
|
||
]);
|
||
assert_eq!(code, 0, "{stderr}");
|
||
write_instance_config(
|
||
dir.path(),
|
||
&serde_json::json!({
|
||
"schemaVersion": 1,
|
||
"id": INSTANCE_ID,
|
||
"versionId": VERSION,
|
||
"isolation": "isolated",
|
||
}),
|
||
);
|
||
|
||
let data = dry_run(&dir);
|
||
assert_eq!(data["isolation"], "isolated", "实例档位压过全局");
|
||
assert_eq!(data["isolation_source"], "instance");
|
||
assert_eq!(
|
||
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
|
||
game_root(&dir).join("versions").join(VERSION)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_isolation_uses_the_recorded_game_dir() {
|
||
let dir = data_root("iso-custom");
|
||
let custom = dir.path().join("mcs").join("farm");
|
||
write_instance_config(
|
||
dir.path(),
|
||
&serde_json::json!({
|
||
"schemaVersion": 1,
|
||
"id": INSTANCE_ID,
|
||
"versionId": VERSION,
|
||
"isolation": "custom",
|
||
"gameDir": custom.display().to_string(),
|
||
}),
|
||
);
|
||
|
||
let data = dry_run(&dir);
|
||
assert_eq!(data["isolation"], "custom");
|
||
assert_eq!(
|
||
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
|
||
custom
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_profile_game_dir_reads_as_custom() {
|
||
// M27 之前的档案只在 profiles.json 里存 game_dir(没有实例设置文件):
|
||
// 显式指定过的目录不能被静默退回全局档。
|
||
let dir = TempDir::new("iso-legacy");
|
||
write_version_with_game_dir(dir.path());
|
||
let legacy = dir.path().join("old-instance-dir");
|
||
write_profiles(dir.path(), Some(&legacy));
|
||
let data = dry_run(&dir);
|
||
assert_eq!(data["isolation"], "custom");
|
||
assert_eq!(
|
||
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
|
||
legacy
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dry_run_does_not_write_instance_state() {
|
||
// 切换检测只在真启动时落盘:`--dry-run` 不该吃掉「切换后首次启动提示一次」。
|
||
let dir = data_root("iso-dry-run");
|
||
let config_path = dir
|
||
.path()
|
||
.join(".ULL")
|
||
.join("config")
|
||
.join("instances")
|
||
.join(format!("{INSTANCE_ID}.json"));
|
||
assert!(!config_path.exists());
|
||
let _ = dry_run(&dir);
|
||
assert!(
|
||
!config_path.exists(),
|
||
"--dry-run 写盘了:{}",
|
||
config_path.display()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_instance_still_reports_available_names() {
|
||
let dir = data_root("iso-unknown");
|
||
let (code, _, stderr) = run_cli(&[
|
||
"launch",
|
||
"不存在的实例",
|
||
"--dry-run",
|
||
"--data-dir",
|
||
&dir.arg(),
|
||
]);
|
||
assert_ne!(code, 0, "未知实例要失败");
|
||
assert!(stderr.contains("生存服"), "提示里要列出可用实例:{stderr}");
|
||
}
|
||
|
||
/// 读实例设置文件(不存在返回 `None`)。
|
||
fn read_instance_config(dir: &TempDir) -> Option<serde_json::Value> {
|
||
let path = dir
|
||
.path()
|
||
.join(".ULL")
|
||
.join("config")
|
||
.join("instances")
|
||
.join(format!("{INSTANCE_ID}.json"));
|
||
let text = std::fs::read_to_string(path).ok()?;
|
||
Some(serde_json::from_str(&text).expect("实例设置应为合法 JSON"))
|
||
}
|
||
|
||
/// 真启动一次(用一个不存在的 java,保证 spawn 必失败、不会真的跑起游戏),
|
||
/// 只为驱动「真启动才写盘」的那段逻辑。
|
||
fn real_launch_that_fails(dir: &TempDir) -> (i32, String, String) {
|
||
let bogus_java = dir.path().join("no-such-java.exe");
|
||
run_cli(&[
|
||
"launch",
|
||
"生存服",
|
||
"--java",
|
||
&bogus_java.display().to_string(),
|
||
"--data-dir",
|
||
&dir.arg(),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn first_real_launch_bootstraps_instance_file() {
|
||
// 回归 P2-3(review r1):profiles.json 有档案但没有实例设置文件(M27 之前的形态),
|
||
// 真启动要补建基线文件,否则切换检测对这批遗留实例永远不启动。
|
||
let dir = data_root("iso-bootstrap");
|
||
assert!(read_instance_config(&dir).is_none(), "前置:还没有设置文件");
|
||
|
||
let (code, _, stderr) = real_launch_that_fails(&dir);
|
||
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
|
||
|
||
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
|
||
assert_eq!(config["versionId"], VERSION);
|
||
assert_eq!(config["id"], INSTANCE_ID);
|
||
// 基线:from == to,且已经标记 warned(不该平白弹一次提示)。
|
||
assert_eq!(config["lastModeChange"]["from"], "isolated");
|
||
assert_eq!(config["lastModeChange"]["to"], "isolated");
|
||
assert_eq!(config["lastModeChange"]["warned"], true);
|
||
assert!(config["lastModeChange"]["at"].is_string());
|
||
assert!(config["lastModeChange"]["probe"]["isolatedSide"].is_number());
|
||
assert!(config["lastModeChange"]["probe"]["sharedSide"].is_number());
|
||
// inherit 不落盘。
|
||
assert!(config.get("isolation").is_none(), "{config}");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_profile_dir_is_recorded_as_custom_on_bootstrap() {
|
||
// 同一批遗留档案里「显式指定过目录」的那部分:补建时把 old profiles.game_dir → custom
|
||
// 固化进实例文件,之后实例文件就是唯一真相。
|
||
let dir = TempDir::new("iso-bootstrap-legacy");
|
||
write_version_with_game_dir(dir.path());
|
||
let legacy = dir.path().join("old-instance-dir");
|
||
write_profiles(dir.path(), Some(&legacy));
|
||
|
||
let (code, _, stderr) = real_launch_that_fails(&dir);
|
||
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
|
||
|
||
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
|
||
assert_eq!(config["isolation"], "custom");
|
||
assert_eq!(
|
||
PathBuf::from(config["gameDir"].as_str().expect("gameDir")),
|
||
legacy
|
||
);
|
||
assert_eq!(config["lastModeChange"]["to"], "custom");
|
||
assert_eq!(config["lastModeChange"]["warned"], true, "基线不弹提示");
|
||
}
|