Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+345
View File
@@ -0,0 +1,345 @@
//! 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, "基线不弹提示");
}