Files
AskaEth 5f8382565e
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
Unlucky Launcher v0.1.0
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
2026-11-01 20:02:49 +08:00

346 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, "基线不弹提示");
}