r2 复审指出「加一家加载器会被逼着表态」只做到一半:编译期只拦「漏 match」,三处消费方 用最省事的 `=> Vanilla` 糊过去既编得过、测试也全绿——新加载器会静默显示成原版,正是本 mission 要消灭的形态(r1 里「Rift 会红」是负样本名字撞上的巧合,不是守卫)。 - presenter 的 `loader_kind_ids_are_the_released_contract` 增加**第 ② 段**:把 core 的 `LoaderKind::ALL.map(as_str)` 与独立手写的七个 id 做**集合相等**比较。加一支而不更新 契约 → 红;改词表 → 红。(r1 删掉 `assert!(ALL.len()==7)` 后缺的正是这一环:数组宽度 只保证「表里那一行写成 7 个」,不保证枚举没多一支。) - 新增 `cli::commands::instances::help_vocabulary_tests::help_lists_every_stable_id`: 按 `LoaderKind::ALL` 遍历 `ull help instances` 的词表行,漏写一个新 id 必红(此前 `help.rs:67` 那段词表没有任何测试,而旧的 `help_documents_the_loader_field_contract` 只查关键词,加一家新加载器时它照样绿)。 - `addon::loader::tests::vocabulary` 补 `installable_list_matches_the_from_core_mapping`: 把唯一还手写的 `LoaderKind::all()` 与 `from_core` 认可的集合对齐(不重抄字面量)。 - 模块文档第 ③ 步改成真话:契约测试会拦「改词表」与「加新种类」;但 `=> Vanilla` 这种 糊法**编得过**,拦住它的是那条集合断言,不是编译器(改名同理,只有契约测试会红)。 - 一并修掉 presenter 那条测试 doc 里指向已被删除的 `MARKERS` / `markers_cover_every_kind_that_has_one` 的引用,改指向现在真实存在的守卫。 - loader.rs 从 398 压到 395(≤400,留 5 行余量;拆测试到子模块需要 scope 放宽到 `crates/core/src/game/loader/**`,超出本任务文件清单,已按规矩记账)。 验收实测(临时加 `Abyss` + 三处 `=> Vanilla` 糊过去,已还原):presenter 契约测试红 (left 8 项 / right 7 项),CLI help 测试红(漏了 `abyss`);core 往返测试与安装侧清单 守卫、CLI 的 `json_rows_*` 仍绿(它们本就管别的事)。
383 lines
13 KiB
Rust
383 lines
13 KiB
Rust
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use unlucky_core::game::LoaderKind as CoreLoaderKind;
|
||
|
||
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
|
||
pub type InstanceId = String;
|
||
|
||
/// 加载器种类(磁贴徽标 + 详情页版本链)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum LoaderKind {
|
||
/// 原版(无加载器)
|
||
Vanilla,
|
||
/// Fabric
|
||
Fabric,
|
||
/// Quilt
|
||
Quilt,
|
||
/// Forge
|
||
Forge,
|
||
/// NeoForge
|
||
NeoForge,
|
||
/// OptiFine
|
||
OptiFine,
|
||
/// LiteLoader
|
||
LiteLoader,
|
||
}
|
||
|
||
impl LoaderKind {
|
||
/// 徽标显示文字。
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Vanilla => "原版",
|
||
Self::Fabric => "Fabric",
|
||
Self::Quilt => "Quilt",
|
||
Self::Forge => "Forge",
|
||
Self::NeoForge => "NeoForge",
|
||
Self::OptiFine => "OptiFine",
|
||
Self::LiteLoader => "LiteLoader",
|
||
}
|
||
}
|
||
|
||
/// 本 DTO 种类 → core 的宽种类。
|
||
///
|
||
/// **结构映射,一个 id 字面量都没有**:词表只有一个真相源
|
||
/// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。
|
||
const fn to_core(self) -> CoreLoaderKind {
|
||
match self {
|
||
Self::Vanilla => CoreLoaderKind::Vanilla,
|
||
Self::Fabric => CoreLoaderKind::Fabric,
|
||
Self::Quilt => CoreLoaderKind::Quilt,
|
||
Self::Forge => CoreLoaderKind::Forge,
|
||
Self::NeoForge => CoreLoaderKind::NeoForge,
|
||
Self::OptiFine => CoreLoaderKind::OptiFine,
|
||
Self::LiteLoader => CoreLoaderKind::LiteLoader,
|
||
}
|
||
}
|
||
|
||
/// core 的宽种类 → 本 DTO 种类([`Self::to_core`] 的反向,同样不含字面量)。
|
||
///
|
||
/// core 给 `game::LoaderKind` 加一支、而 DTO 还没跟上时,这里编不过——这正是要的:
|
||
/// UI 不许「新加载器默默显示成原版」。
|
||
const fn from_core(kind: CoreLoaderKind) -> Self {
|
||
match kind {
|
||
CoreLoaderKind::Vanilla => Self::Vanilla,
|
||
CoreLoaderKind::Fabric => Self::Fabric,
|
||
CoreLoaderKind::Quilt => Self::Quilt,
|
||
CoreLoaderKind::Forge => Self::Forge,
|
||
CoreLoaderKind::NeoForge => Self::NeoForge,
|
||
CoreLoaderKind::OptiFine => Self::OptiFine,
|
||
CoreLoaderKind::LiteLoader => Self::LiteLoader,
|
||
}
|
||
}
|
||
|
||
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。
|
||
///
|
||
/// **委派**给 [`CoreLoaderKind::as_str`](M126):整张词表在 core 只有一份字面量
|
||
/// (0.2.0 已发布契约),这里只剩一次结构映射。
|
||
#[must_use]
|
||
pub fn as_str(self) -> &'static str {
|
||
self.to_core().as_str()
|
||
}
|
||
|
||
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
|
||
///
|
||
/// 七个 id 的匹配委派给 [`CoreLoaderKind::parse`];本方法只额外收 `none` / 空串这两个
|
||
/// **GUI 输入别名**(表单里「不装加载器」的写法)——它们不是对外输出的 id,也绝不参与
|
||
/// [`Self::as_str`]。
|
||
pub fn parse(raw: &str) -> Option<Self> {
|
||
let wanted = raw.trim();
|
||
if wanted.is_empty() || wanted.eq_ignore_ascii_case("none") {
|
||
return Some(Self::Vanilla);
|
||
}
|
||
Some(Self::from_core(CoreLoaderKind::parse(wanted)?))
|
||
}
|
||
|
||
/// 向导里的可选加载器(顺序即展示顺序)。
|
||
pub fn wizard_choices() -> [Self; 5] {
|
||
[
|
||
Self::Vanilla,
|
||
Self::Fabric,
|
||
Self::Quilt,
|
||
Self::Forge,
|
||
Self::NeoForge,
|
||
]
|
||
}
|
||
}
|
||
|
||
/// 加载器 + 版本(例如 Fabric 0.16.5)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct LoaderInfo {
|
||
/// 加载器种类
|
||
pub kind: LoaderKind,
|
||
/// 加载器版本号;原版为空串
|
||
pub version: String,
|
||
}
|
||
|
||
impl Default for LoaderInfo {
|
||
fn default() -> Self {
|
||
Self {
|
||
kind: LoaderKind::Vanilla,
|
||
version: String::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl LoaderInfo {
|
||
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
|
||
pub fn badge(&self) -> String {
|
||
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
|
||
self.kind.label().to_string()
|
||
} else {
|
||
format!("{} {}", self.kind.label(), self.version)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||
pub struct Progress {
|
||
/// 已完成量
|
||
pub done: u64,
|
||
/// 总量;0 = 未知
|
||
pub total: u64,
|
||
}
|
||
|
||
impl Progress {
|
||
/// 0.0~1.0;未知总量返回 0.0。
|
||
pub fn ratio(self) -> f32 {
|
||
if self.total == 0 {
|
||
0.0
|
||
} else {
|
||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
/// 总量是否已知。
|
||
pub fn is_known(self) -> bool {
|
||
self.total > 0
|
||
}
|
||
}
|
||
|
||
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum LaunchStage {
|
||
/// 下载资源
|
||
Download,
|
||
/// 校验文件
|
||
Verify,
|
||
/// 组装命令行并拉起进程
|
||
Launch,
|
||
/// 进程已起、等待游戏窗口(M28 的窗口检测阶段)
|
||
Window,
|
||
}
|
||
|
||
impl LaunchStage {
|
||
/// 磁贴上的阶段文案。
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Download => "下载资源",
|
||
Self::Verify => "校验文件",
|
||
Self::Launch => "组装命令行",
|
||
Self::Window => "等待窗口",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 实例运行时状态(磁贴的四种外观)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||
pub enum InstanceRuntime {
|
||
/// 空闲
|
||
#[default]
|
||
Idle,
|
||
/// 启动管线进行中
|
||
Busy {
|
||
/// 当前阶段
|
||
stage: LaunchStage,
|
||
/// 该阶段进度
|
||
progress: Progress,
|
||
},
|
||
/// 游戏进程存活
|
||
Running {
|
||
/// 进程号(停止/诊断用)
|
||
pid: u32,
|
||
},
|
||
/// 上次启动失败(磁贴显示「重试」)
|
||
Failed {
|
||
/// 失败原因(用户可见文案)
|
||
reason: String,
|
||
},
|
||
}
|
||
|
||
/// 实例摘要:磁贴/列表视图唯一数据源。
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct InstanceSummary {
|
||
/// 稳定 id
|
||
pub id: InstanceId,
|
||
/// 显示名
|
||
pub name: String,
|
||
/// 游戏版本(如 1.20.4)
|
||
pub mc_version: String,
|
||
/// 加载器信息
|
||
pub loader: LoaderInfo,
|
||
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
|
||
pub last_played: Option<String>,
|
||
/// 运行时状态
|
||
pub runtime: InstanceRuntime,
|
||
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
|
||
pub icon: Option<PathBuf>,
|
||
/// 隔离档位(`isolated` / `shared` / `custom`;未知为空)
|
||
pub isolation: String,
|
||
/// 该实例绑定的版本 id(详情页/启动用)
|
||
pub version_id: String,
|
||
/// 版本本体是否在盘上(缺载荷时磁贴亮黄条)
|
||
pub version_present: bool,
|
||
}
|
||
|
||
/// 新建实例向导提交给 host 的请求(已通过向导校验)。
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct CreateRequest {
|
||
/// 目标原版版本 id(`1.20.4`)
|
||
pub version: String,
|
||
/// 加载器
|
||
pub loader: LoaderKind,
|
||
/// 加载器版本(空 = 让 core 取最新)
|
||
pub loader_version: String,
|
||
/// 实例名(空 = 用版本 id / 加载器版本 id)
|
||
pub name: String,
|
||
}
|
||
|
||
impl CreateRequest {
|
||
/// 实际使用的实例名。
|
||
pub fn instance_name(&self) -> String {
|
||
if self.name.trim().is_empty() {
|
||
self.version.clone()
|
||
} else {
|
||
self.name.trim().to_string()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn loader_badges_and_ids() {
|
||
let vanilla = LoaderInfo::default();
|
||
assert_eq!(vanilla.badge(), "原版");
|
||
let fabric = LoaderInfo {
|
||
kind: LoaderKind::Fabric,
|
||
version: "0.16.5".to_string(),
|
||
};
|
||
assert_eq!(fabric.badge(), "Fabric 0.16.5");
|
||
for kind in LoaderKind::wizard_choices() {
|
||
assert_eq!(LoaderKind::parse(kind.as_str()), Some(kind));
|
||
}
|
||
assert_eq!(LoaderKind::parse("fabric"), Some(LoaderKind::Fabric));
|
||
assert_eq!(LoaderKind::parse("unknown"), None);
|
||
}
|
||
|
||
/// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做
|
||
/// **集合比较**——这是「加了一支枚举却没人更新对外词表」的唯一守卫。
|
||
///
|
||
/// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在三处消费方用最省事的
|
||
/// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档
|
||
/// 第 ③ 步)。真正拦住它的是下面第 ② 段的集合断言。
|
||
///
|
||
/// 抓得住:改词表(`neoforge` → `neo_forge`);**新增一家加载器而没主动更新这份契约**
|
||
/// (core 的 `ALL` 多一支 ⇒ 集合不等);DTO 委派接错支(`OptiFine` 映到 `LiteLoader`);
|
||
/// `parse` 收进不该有的别名。
|
||
/// 抓不住:core 的解析规则(那是 `game::loader` 的
|
||
/// `every_stable_id_parses_back_to_its_kind` 等测试的职责),以及真二进制输出面的 JSON
|
||
/// (CLI 的 `json_rows_expose_loader_kind_and_version`)。
|
||
#[test]
|
||
fn loader_kind_ids_are_the_released_contract() {
|
||
const CONTRACT: [(LoaderKind, &str); 7] = [
|
||
(LoaderKind::Vanilla, "vanilla"),
|
||
(LoaderKind::Fabric, "fabric"),
|
||
(LoaderKind::Quilt, "quilt"),
|
||
(LoaderKind::Forge, "forge"),
|
||
(LoaderKind::NeoForge, "neoforge"),
|
||
(LoaderKind::OptiFine, "optifine"),
|
||
(LoaderKind::LiteLoader, "liteloader"),
|
||
];
|
||
|
||
// ① DTO 这一层:每支的 id、反查、大小写。
|
||
for (kind, id) in CONTRACT {
|
||
assert_eq!(kind.as_str(), id, "{kind:?} 的稳定 id 变了");
|
||
assert_eq!(LoaderKind::parse(id), Some(kind), "`{id}` 认不回来");
|
||
let upper = id.to_ascii_uppercase();
|
||
assert_eq!(LoaderKind::parse(&upper), Some(kind), "大写 `{upper}`");
|
||
}
|
||
|
||
// ② core 的词表 ↔ 这份契约:**集合相等**才过(多一支、少一支、改一个字都红)。
|
||
let mut actual: Vec<&str> = CoreLoaderKind::ALL
|
||
.iter()
|
||
.map(|kind| kind.as_str())
|
||
.collect();
|
||
actual.sort_unstable();
|
||
let mut expected: Vec<&str> = CONTRACT.iter().map(|(_, id)| *id).collect();
|
||
expected.sort_unstable();
|
||
assert_eq!(
|
||
actual, expected,
|
||
"core 的 `LoaderKind::ALL` 必须逐字等于对外契约:加一家加载器时先改这里 + help"
|
||
);
|
||
|
||
// GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。
|
||
for alias in ["none", "", " "] {
|
||
assert_eq!(
|
||
LoaderKind::parse(alias),
|
||
Some(LoaderKind::Vanilla),
|
||
"`{alias}`"
|
||
);
|
||
}
|
||
assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧");
|
||
}
|
||
|
||
#[test]
|
||
fn progress_ratio_handles_unknown_total() {
|
||
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);
|
||
assert!((Progress { done: 1, total: 4 }.ratio() - 0.25).abs() < f32::EPSILON);
|
||
}
|
||
|
||
#[test]
|
||
fn create_request_defaults_name_to_version() {
|
||
let request = CreateRequest {
|
||
version: "1.20.4".to_string(),
|
||
loader: LoaderKind::Vanilla,
|
||
loader_version: String::new(),
|
||
name: " ".to_string(),
|
||
};
|
||
assert_eq!(request.instance_name(), "1.20.4");
|
||
}
|
||
|
||
#[test]
|
||
fn instance_summary_is_serde_round_trip_safe() {
|
||
let instance = InstanceSummary {
|
||
id: "inst-1".to_string(),
|
||
name: "生存".to_string(),
|
||
mc_version: "1.20.4".to_string(),
|
||
loader: LoaderInfo {
|
||
kind: LoaderKind::Fabric,
|
||
version: "0.16.5".to_string(),
|
||
},
|
||
last_played: Some("2026-10-31T12:00:00.000Z".to_string()),
|
||
runtime: InstanceRuntime::Busy {
|
||
stage: LaunchStage::Window,
|
||
progress: Progress { done: 1, total: 2 },
|
||
},
|
||
icon: None,
|
||
isolation: "isolated".to_string(),
|
||
version_id: "1.20.4".to_string(),
|
||
version_present: true,
|
||
};
|
||
let text = serde_json::to_string(&instance).expect("序列化");
|
||
let back: InstanceSummary = serde_json::from_str(&text).expect("反序列化");
|
||
assert_eq!(back, instance);
|
||
}
|
||
}
|