From be37c179621c980200552f547f4fa5998c22dd84 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 16:06:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(core):=20M126=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E7=A8=B3=E5=AE=9A=20id=20=E8=AF=8D=E8=A1=A8=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=88=B0=20game::LoaderKind::as=5Fstr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M122 把「id → 加载器」的解析收到 core::game::loader::InstanceLoader::parse,但 「种类 → 对外稳定 id」的命名词表还散在多处,其中 install 侧那一份只靠一句「不要改名」 的注释约束,MARKERS 更是第四张无守卫字面量表。 - core::game::LoaderKind 新增 as_str(**全仓唯一一份 id 字面量**)、has_marker (穷尽 match:新种类必须表态认不认 id 标记)、parse(as_str 的反查)与 ALL(清单, 顺序即标记优先级);MARKERS 的标记串改为取自 as_str,不再是独立字面量。 - addon::loader::LoaderKind::as_str 改成委派(结构映射,无字面量):安装侧拼出的 -- 与解析侧从此同源。 - presenter::dto::instances::LoaderKind 的 as_str/parse 委派给 core(to_core/from_core 是结构映射),删掉第四份词表;CLI 删掉 loader_kind_id 翻译层,直接调 core。 - 测试:core 新增 markers_cover_every_kind_that_has_one(MARKERS↔ALL↔has_marker↔as_str 逐格对齐 + 按标记串 round-trip + neoforge 先于 forge);presenter 新增 loader_kind_ids_are_the_released_contract(七个 id 逐字钉住 + 反查 + 别名隔离)。 - 对外输出未变:真二进制对同一组 7 类实例的 `instances list --json` 输出逐字节相同 (md5 0341752658ca28e020ba48c31a51a124),人读输出同样逐字节相同。 file 行数:core/game/loader.rs 337→399(≤400 硬约束,文档按密度重排、事实未丢)。 --- crates/cli/src/commands/instances.rs | 34 ++-- crates/core/src/addon/loader/mod.rs | 15 +- crates/core/src/game/loader.rs | 234 ++++++++++++++++---------- crates/presenter/src/dto/instances.rs | 104 +++++++++--- 4 files changed, 253 insertions(+), 134 deletions(-) diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index bb61461..023d3f9 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -8,8 +8,10 @@ //! //! 每一行实例带一个 `loader` 对象:`{"kind": <稳定 id>, "version": <版本段>}`。它是 //! **`version_id` 的解析结果**,解析本身不在这里——[`unlucky_core::game::InstanceLoader::parse`] -//! 是全仓唯一的 id → 加载器入口(M122 收敛)。CLI 只做「种类 → 稳定 id」的翻译,**不重写 -//! 任何 id 匹配**:那份规则一旦两处各算一遍就会漂(M120 的「刚装的 Fabric 显示成原版」)。 +//! 是全仓唯一的 id → 加载器入口(M122 收敛)。种类 → 稳定 id 的词表同样不在本 crate: +//! [`unlucky_core::game::LoaderKind::as_str`] 是全仓唯一一份 id 字面量(M126 收敛),这里直接 +//! 调它,**连翻译层都没有**——词表与 id 匹配一样,两处各算一遍就会漂(M120 的「刚装的 +//! Fabric 显示成原版」)。 //! //! ## 约定一:`loader` 恒存在,原版是 `{"kind":"vanilla","version":""}` //! @@ -44,7 +46,7 @@ use std::path::PathBuf; use serde_json::{json, Value}; use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout; -use unlucky_core::game::{InstanceLoader, LoaderKind, ProfileList}; +use unlucky_core::game::{InstanceLoader, ProfileList}; use crate::context::Ctx; use crate::error::CliError; @@ -128,33 +130,17 @@ fn option_path(path: Option<&PathBuf>) -> Option { /// 版本 id → `loader` 对象(`{"kind": …, "version": …}`)。 /// -/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);这里**不写任何 id 匹配**,只把解析结果 -/// 翻成 JSON。原版同样给出对象(`kind = vanilla`、`version = ""`),不缺键、不为 `null` -/// ——见模块文档的「约定一」。 +/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);种类 → 稳定 id 走 +/// [`unlucky_core::game::LoaderKind::as_str`](词表的唯一真相源,M126)。这里既**不写 id +/// 匹配**,也**不写词表**:两样都只该有一份,且都在 core。原版同样给出对象 +/// (`kind = vanilla`、`version = ""`),不缺键、不为 `null`——见模块文档的「约定一」。 fn loader_json(version_id: &str) -> Value { let parsed = InstanceLoader::parse(version_id); json!({ - "kind": loader_kind_id(parsed.kind), + "kind": parsed.kind.as_str(), "version": parsed.version, }) } -/// 加载器种类 → 稳定 id。 -/// -/// 词表与 `--loader` 同源(`vanilla` / `fabric` / `quilt` / `forge` / `neoforge`),另加两个 -/// **只能识别、不能安装**的 `optifine` / `liteloader`。穷尽 match:core 加一支就编不过, -/// 不会静默漏报成 `vanilla`。 -fn loader_kind_id(kind: LoaderKind) -> &'static str { - match kind { - LoaderKind::Vanilla => "vanilla", - LoaderKind::Fabric => "fabric", - LoaderKind::Quilt => "quilt", - LoaderKind::Forge => "forge", - LoaderKind::NeoForge => "neoforge", - LoaderKind::OptiFine => "optifine", - LoaderKind::LiteLoader => "liteloader", - } -} - #[cfg(test)] mod tests; diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs index d8eee17..2327f80 100644 --- a/crates/core/src/addon/loader/mod.rs +++ b/crates/core/src/addon/loader/mod.rs @@ -74,14 +74,19 @@ impl LoaderKind { } } - /// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。 + /// 稳定字符串 id(进版本 id、JSON 输出与日志)。 + /// + /// 词表**不在本模块**:[`crate::game::LoaderKind::as_str`] 是「种类 → 对外稳定 id」的唯一 + /// 真相源(M126)。这里只做**结构映射**(一个 id 字面量都没有)——安装侧拼出来的 + /// `--` 必须能被 [`crate::game::InstanceLoader::parse`] 认回来,委派让 + /// 「两边各写一份、改一边忘了另一边」这件事物理上做不到。 #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::Fabric => "fabric", - Self::Quilt => "quilt", - Self::NeoForge => "neoforge", - Self::Forge => "forge", + Self::Fabric => crate::game::LoaderKind::Fabric.as_str(), + Self::Quilt => crate::game::LoaderKind::Quilt.as_str(), + Self::NeoForge => crate::game::LoaderKind::NeoForge.as_str(), + Self::Forge => crate::game::LoaderKind::Forge.as_str(), } } diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index 1c78751..8d7da4d 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -1,31 +1,32 @@ -//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122)。 +//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122),外加「种类 → 对外稳定 id」的 +//! **全仓唯一**命名词表(M126,见 [`LoaderKind::as_str`])。 //! -//! # 为什么要有这一个模块 +//! # 为什么要这一个模块 //! -//! 「这个实例装的什么加载器」在 UI 上是一句话,在代码里却有两个可能的来源:版本 JSON 的 +//! 「这个实例装的什么加载器」在 UI 上是一句话,在代码里却有两个来源:版本 JSON 的 //! `libraries[]`(权威但要多读一次盘)与**版本 id 的字符串**(磁贴/列表最省事的那条)。 -//! 只要有人按 id 猜,就必须只有**一处**在猜——两处各算一遍,迟早一边先漂,而用户看到的 -//! 是「我刚装的 Fabric 显示成原版」这种信任问题(M120 复审发现的老 bug:解析端只认 -//! `fabric-loader-…`,认不出本启动器自己生成的 `1.20.4-fabric-0.16.5`)。 +//! 只要有人按 id 猜就必须只有**一处**在猜——两处各算一遍迟早一边先漂,用户看到的就是 +//! 「我刚装的 Fabric 显示成原版」(M120 复审的老 bug:解析端只认 `fabric-loader-…`,认不出 +//! 自家生成的 `1.20.4-fabric-0.16.5`)。所以 presenter 的实例页、CLI 的实例总览、以后任何 +//! 「按 id 认加载器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。 +//! 生成端([`crate::addon::loader::LoaderKind::version_id`] 拼 `--`)与 +//! 解析端的一致性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住——那条测试 +//! **从生成端取 id**,不手抄。OptiFine / LiteLoader 本启动器不能装(见 +//! `presenter::host::install`),只能从别的启动器继承进来。 //! -//! 因此:presenter 的实例页、CLI 的实例总览、以后任何「按 id 认加载器」的地方, -//! 一律调 [`InstanceLoader::parse`]。**不许**在别处再写一份标记匹配。 +//! # 命名词表只有一份字面量(M126) //! -//! # id 的生成端(真相的另一半) +//! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` / +//! `neoforge` / `optifine` / `liteloader`。其中只有 [`LoaderKind::as_str`] 是字面量;安装侧与 +//! presenter 的 `as_str` 是**委派**(结构映射,写不进串),CLI 直接调,`MARKERS` 的标记串也 +//! 取自它。加一家加载器要动: //! -//! 本启动器自己装的加载器,id 由 [`crate::addon::loader::LoaderKind::version_id`] 拼成 -//! `--`: -//! -//! | 加载器 | 真实 id 样本 | -//! |--------|--------------| -//! | Fabric | `1.20.4-fabric-0.16.5` | -//! | Quilt | `1.20.4-quilt-0.27.0`(beta 版如 `1.20.1-quilt-0.20.0-beta.9`) | -//! | Forge | `1.20.1-forge-47.2.0` | -//! | NeoForge | `1.21.1-neoforge-21.1.80` | -//! -//! 生成端与解析端的一致性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住: -//! 那条测试**从生成端取 id**,不是手抄字符串。OptiFine / LiteLoader 本启动器不能装 -//! (见 `presenter::host::install` 的范围说明),只能从别的启动器继承进来,口径见下。 +//! 1. `LoaderKind` 加一支 → **[`LoaderKind::as_str`] / [`LoaderKind::has_marker`] / +//! presenter 的 `to_core`·`from_core` 编不过**(能装的话再加安装侧 `as_str` 一支); +//! 2. [`LoaderKind::ALL`] 加一格、`MARKERS` 加一行、尺寸锚改成 8 → **只能靠锚与测试** +//! (Rust 没有「枚举有哪几支」的编译期反射),漏了是「有 id 但认不出」,由 +//! [`tests::markers_cover_every_kind_that_has_one`] 抓; +//! 3. `ull help instances` 的词表行与 CLI 的 `json_rows_expose_loader_kind_and_version` 一起红。 //! //! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**) //! @@ -38,18 +39,8 @@ //! 3. 例外:版本段有 **≥2 段** 且**最后一段**长得像 MC 版本(`1.20.1` 这种纯数字点号)时 //! 砍掉最后一段——那是老 HMCL 的 `fabric-loader--` 写法。一段都不剩的 //! `0.16.5` 不会被砍;`0.20.0-beta.9` 的末段不是数字,也不会被砍; -//! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。 -//! -//! # 认得出、也认不出的 -//! -//! 认得出:上面四种自家 id;老 HMCL 的 `fabric-loader-0.16.5-1.20.1` / -//! `quilt-loader-0.24.0-1.20.1`;CF 风格的 `forge-47.2.0`;PCL 的 -//! `1.13.2-OptiFine_HD_U_E6` 与 `1.12.2-LiteLoader`(LiteLoader 的 id 里没有版本段, -//! 拿到空串是正常的)。 -//! -//! 认不出:把加载器信息只写进版本 JSON、id 里一点痕迹都没有的实例(例如整包导入时把 -//! 版本重命名成 `我的整合包`)——那种只能去读 `libraries[]`,不在本模块的能力范围内, -//! 结果会诚实地返回「原版」。 +//! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。只把加载器写进版本 JSON、id 里没痕迹的实例 +//! (整包导入时被重命名成 `我的整合包` 那种)会**诚实**返回「原版」——那不等于没装加载器。 /// 实例面向的加载器种类(比 [`crate::addon::loader::LoaderKind`] 宽)。 /// @@ -74,6 +65,82 @@ pub enum LoaderKind { LiteLoader, } +impl LoaderKind { + /// 全部种类,**顺序即标记的匹配优先级**;原版没有标记,排最后。 + /// + /// `NeoForge` 必须在 `Forge` 前(两者都是段前缀匹配,今天互不干扰,但顺序写死能防以后 + /// 有人把匹配放宽成「包含」)。这张表同时是枚举的清单,漏掉一格编译器抓不到。 + pub const ALL: [Self; 7] = [ + Self::NeoForge, + Self::Fabric, + Self::Quilt, + Self::Forge, + Self::OptiFine, + Self::LiteLoader, + Self::Vanilla, + ]; + + /// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。 + /// + /// **全仓唯一的一份 id 字面量**,也是 0.2.0 已发布契约。穷尽 match:加一支枚举就编不过。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Vanilla => "vanilla", + Self::Fabric => "fabric", + Self::Quilt => "quilt", + Self::Forge => "forge", + Self::NeoForge => "neoforge", + Self::OptiFine => "optifine", + Self::LiteLoader => "liteloader", + } + } + + /// 这一支会不会从版本 id 的标记里认出来(原版不会)。穷尽 match:加一支必须表态。 + #[must_use] + pub const fn has_marker(self) -> bool { + match self { + Self::Vanilla => false, + Self::Fabric + | Self::Quilt + | Self::Forge + | Self::NeoForge + | Self::OptiFine + | Self::LiteLoader => true, + } + } + + /// 稳定 id → 种类([`Self::as_str`] 的反查;整串全等,大小写不敏感,不认前缀与别名)。 + /// + /// 别和 [`InstanceLoader::parse`] 搞混:那个认的是**版本 id**(切段、认前缀、给版本段), + /// 这个认的是**词表里的一个 id**。 + #[must_use] + pub fn parse(raw: &str) -> Option { + let wanted = raw.trim(); + Self::ALL + .into_iter() + .find(|kind| kind.as_str().eq_ignore_ascii_case(wanted)) + } +} + +/// 词表尺寸是契约的一部分:加一支枚举并放进 [`LoaderKind::ALL`] 时这行会红,逼作者确认 +/// 「对外词表真的要多一个 id 了」。 +const _: () = assert!(LoaderKind::ALL.len() == 7); + +/// 标记 → 种类,**顺序即优先级**(同一段里先命中的算数)。 +/// +/// 标记串取自 [`LoaderKind::as_str`](没有第二份字面量);成员与 +/// [`LoaderKind::has_marker`] 是否一致由 [`tests::markers_cover_every_kind_that_has_one`] +/// 钉住——加一支枚举而漏加一行,那条会红。 +const MARKERS: [(LoaderKind, &'static str); 6] = [ + (LoaderKind::NeoForge, LoaderKind::NeoForge.as_str()), + (LoaderKind::Fabric, LoaderKind::Fabric.as_str()), + (LoaderKind::Quilt, LoaderKind::Quilt.as_str()), + (LoaderKind::Forge, LoaderKind::Forge.as_str()), + (LoaderKind::OptiFine, LoaderKind::OptiFine.as_str()), + (LoaderKind::LiteLoader, LoaderKind::LiteLoader.as_str()), +]; + /// 一个版本 id 解析出来的加载器:种类 + 版本段(原版为空串)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstanceLoader { @@ -83,24 +150,10 @@ pub struct InstanceLoader { pub version: String, } -/// 标记 → 种类,**顺序即优先级**(同一段里先命中的算数)。 -/// -/// `neoforge` 必须排在 `forge` 前面:两者都是「段前缀」匹配,`neoforge…` 段若先按 -/// `forge` 判,`neoforge` 里没有 `forge` 前缀不会误判,但把顺序写死能防以后有人把匹配 -/// 改成「包含」时踩坑。 -const MARKERS: [(LoaderKind, &str); 6] = [ - (LoaderKind::NeoForge, "neoforge"), - (LoaderKind::Fabric, "fabric"), - (LoaderKind::Quilt, "quilt"), - (LoaderKind::Forge, "forge"), - (LoaderKind::OptiFine, "optifine"), - (LoaderKind::LiteLoader, "liteloader"), -]; - impl InstanceLoader { /// 从版本 id 解析加载器(纯字符串计算,不读盘)。 /// - /// 规则见模块文档;认不出加载器标记时返回 [`LoaderKind::Vanilla`] + 空版本段。 + /// 规则见模块文档;认不出标记时返回 [`LoaderKind::Vanilla`] + 空版本段。 #[must_use] pub fn parse(version_id: &str) -> Self { let segments: Vec<&str> = version_id.trim().split('-').collect(); @@ -132,7 +185,7 @@ impl InstanceLoader { } } -/// 这一段的**前缀**是哪个加载器标记;命中时连「标记之后剩下的部分」一起给出。 +/// 这一段的**前缀**是哪个加载器标记;命中时连它后面剩下的部分一起给出。 fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> { for (kind, marker) in MARKERS { let Some(rest) = strip_prefix_ascii_ci(segment, marker) else { @@ -143,8 +196,7 @@ fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> { None } -/// 去掉标记后面可选的 `loader` 后缀(`fabric-loader` 的版本段要从 `0.16.5` 起算,不是 -/// 从 `loader-0.16.5` 起算)。分隔符 `-` / `_` 一并吞掉。 +/// 去掉标记后面可选的 `loader` 后缀(版本段从 `0.16.5` 起算,不是 `loader-0.16.5`)。 fn strip_loader_suffix(rest: &str) -> &str { let trimmed = trim_separators(rest); match strip_prefix_ascii_ci(trimmed, "loader") { @@ -177,11 +229,8 @@ fn join_version(parts: &[&str]) -> String { kept.join("-") } -/// 这一段长得像不像 MC 版本(`1.20` / `1.20.1` / `1.7.10`:全数字 + 至少一个点)。 -/// -/// 只用于「砍 HMCL 尾巴」那一条:它要求版本段前面还有内容(`≥2` 段),所以 `0.16.5` -/// 这种单独的加载器版本不会被当成 MC 版本砍掉。`0.20.0-beta.9` 的末段 `beta.9` 不是纯 -/// 数字点号,也不会被砍。 +/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号)。只用于「砍 HMCL 尾巴」,那一条 +/// 要求前面还有内容(`≥2` 段),所以单独的 `0.16.5` 不会被砍,`0.20.0-beta.9` 也不会。 fn looks_like_mc_version(text: &str) -> bool { text.contains('.') && text @@ -195,12 +244,9 @@ mod tests { /// 自家 id 的真值表:**从生成端取 id**,不手抄。 /// - /// 抓得住:生成端换了 id 形状(改了 [`crate::addon::loader::LoaderKind::version_id`] - /// 的分隔符/顺序)而解析端没跟——这条会红;以及解析端只认老 HMCL 口径、认不出自家 - /// `-fabric-` / `-quilt-`(M122 修的那个 bug,改回旧口径这条必红)。 - /// 抓不住:加载器版本里带 `-` 时版本段被截断的**同类**情形——`0.16.5-beta.1` 与 - /// `0.20.0-beta.9` 两个样本是专门为它写的;Forge/NeoForge 的版本号在真实世界里不带 - /// `-`,所以那边没有样本,将来真出现也只能靠人补。 + /// 抓得住:生成端换了 id 形状而解析端没跟、解析端退回只认老 HMCL 口径(M122 的 bug)。 + /// 抓不住:加载器版本自己带 `-` 的**其他**情形(`0.16.5-beta.1` / `0.20.0-beta.9` 两个 + /// 样本专为它写;Forge/NeoForge 的真实版本号不带 `-`,出现了只能靠人补样本)。 #[test] fn own_version_ids_parse_back_to_their_loader() { use crate::addon::loader::LoaderKind as InstallKind; @@ -238,17 +284,11 @@ mod tests { /// 第三方前缀(OptiFine / LiteLoader):它们**不是** `--` 口径。 /// - /// 样本来源(本仓库里可查的真代码/真仓库): - /// - PCL:`PCL/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.vb:441` - /// `Entry.Inherit & "-OptiFine_" & …` ⇒ `1.13.2-OptiFine_HD_U_E6`; - /// - PCL:`PCL/Plain Craft Launcher 2/Pages/PageDownload/ModDownloadLib.vb:793` - /// `DownloadInfo.Inherit & "-LiteLoader"` ⇒ `1.12.2-LiteLoader`(**没有版本段**); - /// - `_pre1` 尾巴来自 OptiFine 自己的 preview 命名(PCL 的 `preview_OptiFine_…` jar 名)。 - /// - /// 抓得住:`optifine` / `liteloader` 的标记被当成「必须带 `-`」或「必须小写」 - /// (旧口径 `split_once("optifine")` 对 `OptiFine_…` 大小写敏感、对 `_` 不认); - /// 抓不住:OptiFine 的 maven 坐标形式(`optifine:OptiFine:1.12.2_HD_U_C7`)——那不是 - /// 版本 id,出现在 id 里时按下面「版本段」的规则处理,未做特判。 + /// 样本来源:PCL 的 `Modules/Minecraft/ModDownload.vb:441`(`-OptiFine_…`)与 + /// `Pages/PageDownload/ModDownloadLib.vb:793`(`-LiteLoader`,**没有版本段**);`_pre1` + /// 尾巴来自 OptiFine 自己的 preview 命名。 + /// 抓得住:标记被当成「必须带 `-`」或「必须小写」(旧口径 `split_once("optifine")`)。 + /// 抓不住:OptiFine 的 maven 坐标形式(`optifine:OptiFine:1.12.2_HD_U_C7`),未做特判。 #[test] fn third_party_prefixes_are_recognized() { let cases = [ @@ -269,14 +309,12 @@ mod tests { } } - /// 老 HMCL / CF 口径(`fabric-loader--`、`forge-`),本轮不制造新写法, - /// 但存量实例还会按它们出现,行为不许退化。 + /// 老 HMCL / CF 口径(`fabric-loader--`、`forge-`):存量实例还会按它们 + /// 出现,行为不许退化。 /// - /// 抓得住:砍 MC 尾巴的规则被删(版本段会变成 `0.16.5-1.20.1`);以及 `loader` 后缀被 - /// 当成版本段的开头(会变成 `loader-0.16.5`)。 - /// 抓不住:`forge-47.2.0-1.20.1` 这类「forge 也带 MC 尾巴」的写法——见下面那行断言, - /// 末段会被砍掉,但这是本测试**手工声明**的口径,不是从代码里查到的真实格式 - /// (本仓库的老 HMCL 分支已改成「清单 + patch」,不再生成 per-loader 版本 id)。 + /// 抓得住:砍 MC 尾巴的规则被删(版本段变成 `0.16.5-1.20.1`)、`loader` 后缀被当成版本段。 + /// 抓不住:`forge-47.2.0-1.20.1` 是**手工声明**的口径,不是查到的真实格式(本仓库的老 + /// HMCL 分支已改成「清单 + patch」)。 #[test] fn foreign_loader_style_ids_still_parse() { let cases = [ @@ -299,11 +337,9 @@ mod tests { /// 反向锚:普通版本 id(原版、快照、预发布)**不许**被认成加载器。 /// - /// 抓得住:解析端退化成「只要有 id 就当加载器」或标记匹配放宽成「包含子串」 - /// (`1.20.4-pre1` 之类一旦被某个标记咬住就会红——这条列出的样本都不含标记,真正的 - /// 子串误伤要另外构造,见下条断言的注释)。 - /// 抓不住:**含加载器名字样的原版 id**(例如快照 `23w45a` 没事,但假设将来出现 - /// `1.20.4-forge` 这样的原版 id,只能靠人的判断——本模块按标记优先,会认成 Forge)。 + /// 抓得住:解析端退化成「有 id 就当加载器」、匹配放宽成「包含子串」。 + /// 抓不住:**含加载器名字样的原版 id**(真出现 `1.20.4-forge` 这种原版 id 会被认成 + /// Forge——本模块按标记优先)。 #[test] fn plain_version_ids_stay_vanilla() { for id in [ @@ -334,4 +370,30 @@ mod tests { assert_eq!(parsed.kind, LoaderKind::Vanilla, "id `{id}`"); } } + + /// 词表 ↔ 解析表:`MARKERS` 必须与 [`LoaderKind::ALL`] / [`has_marker`] / [`as_str`] + /// 逐格对齐,并且按标记串能 round-trip 回同一支。 + /// + /// 抓得住:加一支枚举漏加 `MARKERS` 一行(`Vec` 逐项比对,顺序也算,所以 `NeoForge` + /// 被排到 `Forge` 后面同样红);`MARKERS` 被改回手写字面量并写错串。抓不住:词表字面值 + /// 本身(改名时两侧同源不会红)——那是对外契约测试的职责,见 + /// `cli::commands::instances::tests::json_rows_expose_loader_kind_and_version`。 + /// + /// [`has_marker`]: LoaderKind::has_marker + /// [`as_str`]: LoaderKind::as_str + #[test] + fn markers_cover_every_kind_that_has_one() { + let listed: Vec = MARKERS.iter().map(|(kind, _)| *kind).collect(); + let expected: Vec = LoaderKind::ALL + .into_iter() + .filter(|kind| kind.has_marker()) + .collect(); + assert_eq!(listed, expected, "`MARKERS` 必须正好是「认标记」的那几支"); + for (kind, marker) in MARKERS { + assert_eq!(marker, kind.as_str(), "标记串必须就是稳定 id"); + let parsed = InstanceLoader::parse(&format!("1.20.4-{marker}-0.16.5")); + assert_eq!(parsed.kind, kind, "标记 `{marker}` 的版本 id"); + assert_eq!(parsed.version, "0.16.5", "标记 `{marker}` 的版本段"); + } + } } diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 47b9078..4e72e31 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use unlucky_core::game::LoaderKind as CoreLoaderKind; + /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 pub type InstanceId = String; @@ -40,31 +42,58 @@ impl LoaderKind { } } - /// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。 - pub fn as_str(self) -> &'static str { + /// 本 DTO 种类 → core 的宽种类。 + /// + /// **结构映射,一个 id 字面量都没有**:词表只有一个真相源 + /// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。 + const fn to_core(self) -> CoreLoaderKind { match self { - Self::Vanilla => "vanilla", - Self::Fabric => "fabric", - Self::Quilt => "quilt", - Self::Forge => "forge", - Self::NeoForge => "neoforge", - Self::OptiFine => "optifine", - Self::LiteLoader => "liteloader", + 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 { - Some(match raw.trim().to_ascii_lowercase().as_str() { - "vanilla" | "none" | "" => Self::Vanilla, - "fabric" => Self::Fabric, - "quilt" => Self::Quilt, - "forge" => Self::Forge, - "neoforge" => Self::NeoForge, - "optifine" => Self::OptiFine, - "liteloader" => Self::LiteLoader, - _ => return None, - }) + 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)?)) } /// 向导里的可选加载器(顺序即展示顺序)。 @@ -252,6 +281,43 @@ mod tests { assert_eq!(LoaderKind::parse("unknown"), None); } + /// DTO 的 `as_str` / `parse` 是 core 词表的**委派**(M126),而词表本身是**对外契约**: + /// 七个 id 在这里独立抄一遍(0.2.0 已发布形状,改名等于违约)。 + /// + /// 抓得住:core 的 `as_str` 或委派接线改错(`OptiFine` 映到 `LiteLoader`、映射漏了哪支); + /// 词表改字/改大小写(`neoforge` → `neo_forge`);`parse` 收进了不该有的别名(`neo-forge`)。 + /// 抓不住:core 的 `ALL` / `MARKERS` 之间对不对得上(那是 core 的 + /// `markers_cover_every_kind_that_has_one` 的职责),以及真二进制输出面上的 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"), + ]; + 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}`"); + } + // GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。 + for alias in ["none", "", " "] { + assert_eq!( + LoaderKind::parse(alias), + Some(LoaderKind::Vanilla), + "`{alias}`" + ); + } + assert_eq!(LoaderKind::parse("neo-forge"), None); + assert_eq!(LoaderKind::parse("rift"), None); + } + #[test] fn progress_ratio_handles_unknown_total() { assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0); From a322cfcbfee391418957b39d1ee41a18ba646f2f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 16:45:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(core):=20M126=20r1=20=E8=BF=94?= =?UTF-8?q?=E5=B7=A5=EF=BC=9A=E8=AF=8D=E8=A1=A8=E6=94=B9=E7=94=B1=E5=AE=8F?= =?UTF-8?q?=E5=8D=95=E8=A1=A8=E7=94=9F=E6=88=90=EF=BC=8C=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E4=BE=A7=E8=A7=A3=E6=9E=90=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的三条: P1(CI 会红):`const MARKERS: [(LoaderKind, &'static str); 6]` 触发 clippy::redundant_static_lifetimes(`error: constants have by default a 'static lifetime`)。返工直接**删掉 MARKERS**:标记串就取 `LoaderKind::as_str()`,候选顺序取 `LoaderKind::ALL`,认不认标记看 `has_marker()`——解析表既没有独立字面量,也没有独立清单。 P2-1:「漏加 ALL 不会被任何东西拦住」是真话,之前的 doc 覆盖声明是假的。返工用 `loader_kind_vocab!` 宏从**一张表**一次生成枚举 / `ALL` / `as_str` / `has_marker`: 「加了一支却忘了进清单」在物理上不可能(没有第二份清单可写)。尺寸锚随之删除(`[Self; N]` 的宽度本来就由表决定),`parse` 留在宏外的独立 impl 里。 P2-2:「全仓唯一一份 id 字面量」原先不成立——安装侧 `LoaderKind::parse` 还手写了一份反向 匹配表,并带一个只进不出的 `neo-forge` 别名。返工让 install 侧 `parse` **委派** `core::game::LoaderKind::parse`,只额外收 `neo-forge`(Modrinth 拼法)这一个输入别名, 并新增 `addon::loader::tests::vocabulary` 覆盖「委派没接错 + 别名只进不出 + 装不了的几家 返回 None」。 测试:core 的 `markers_cover_every_kind_that_has_one` 换成 `every_stable_id_parses_back_to_its_kind`(往返 + 顺序优先级),仍是「改名不可见、只有契约 测试能抓」的分工,doc 里写明。 门禁:fmt OK;clippy -p unlucky-core -p unlucky-presenter -p unlucky-cli --all-targets -- -D warnings 干净;三 crate 测试 core lib 401 / presenter 220 / cli 41 全 ok。 文件行数:loader.rs 397(≤400)。 --- crates/core/src/addon/loader/mod.rs | 32 ++- crates/core/src/addon/loader/tests/mod.rs | 1 + .../core/src/addon/loader/tests/vocabulary.rs | 63 +++++ crates/core/src/game/loader.rs | 261 +++++++++--------- 4 files changed, 219 insertions(+), 138 deletions(-) create mode 100644 crates/core/src/addon/loader/tests/vocabulary.rs diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs index 2327f80..a827521 100644 --- a/crates/core/src/addon/loader/mod.rs +++ b/crates/core/src/addon/loader/mod.rs @@ -62,15 +62,33 @@ pub enum LoaderKind { } impl LoaderKind { - /// 解析命令行里的加载器名(大小写不敏感;`neo-forge` 是 Modrinth 的另一种拼法)。 + /// 解析命令行 / 整合包清单里的加载器名(大小写不敏感)。 + /// + /// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表唯一真相源,M126), + /// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack + /// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出 + /// 它。识别得出但本启动器装不了的几家(原版 / OptiFine / LiteLoader)返回 `None`。 #[must_use] pub fn parse(raw: &str) -> Option { - match raw.trim().to_ascii_lowercase().as_str() { - "fabric" => Some(Self::Fabric), - "quilt" => Some(Self::Quilt), - "neoforge" | "neo-forge" => Some(Self::NeoForge), - "forge" => Some(Self::Forge), - _ => None, + let wanted = raw.trim(); + if wanted.eq_ignore_ascii_case("neo-forge") { + return Some(Self::NeoForge); + } + Self::from_core(crate::game::LoaderKind::parse(wanted)?) + } + + /// core 的宽种类 → 本模块的四家(`None` = 识别得出但本启动器装不了)。 + /// + /// 穷尽 match:core 加一支就编不过,逼作者决定「它能不能装」。 + const fn from_core(kind: crate::game::LoaderKind) -> Option { + match kind { + crate::game::LoaderKind::Fabric => Some(Self::Fabric), + crate::game::LoaderKind::Quilt => Some(Self::Quilt), + crate::game::LoaderKind::NeoForge => Some(Self::NeoForge), + crate::game::LoaderKind::Forge => Some(Self::Forge), + crate::game::LoaderKind::Vanilla + | crate::game::LoaderKind::OptiFine + | crate::game::LoaderKind::LiteLoader => None, } } diff --git a/crates/core/src/addon/loader/tests/mod.rs b/crates/core/src/addon/loader/tests/mod.rs index 4d2f93f..e31eb44 100644 --- a/crates/core/src/addon/loader/tests/mod.rs +++ b/crates/core/src/addon/loader/tests/mod.rs @@ -7,6 +7,7 @@ mod fabric; mod forge; mod maven; mod plan; +mod vocabulary; /// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。 pub(crate) const FABRIC_PROFILE: &str = r#"{ diff --git a/crates/core/src/addon/loader/tests/vocabulary.rs b/crates/core/src/addon/loader/tests/vocabulary.rs new file mode 100644 index 0000000..b6e0458 --- /dev/null +++ b/crates/core/src/addon/loader/tests/vocabulary.rs @@ -0,0 +1,63 @@ +//! 安装侧词表与 `crate::game` 词表的对接:委派有没有接错、别名有没有漏进产出。 +//! +//! 词表本体(七个对外 id)在 `crate::game::loader`,这里只验**安装侧这一半**: +//! [`LoaderKind::as_str`] 委派给 core,[`LoaderKind::parse`] 委派 core 的解析并只多收一个 +//! Modrinth 输入别名。为什么放在安装侧的测试目录:这一半的存在理由是「装出来的版本 id 得 +//! 能被认回来」,那正好是生成端与解析端的接缝。 + +use crate::addon::loader::LoaderKind; +use crate::game::{InstanceLoader, LoaderKind as Wide}; + +/// `as_str` 是 core 词表的委派:四家装得上的一一对应,且绝不产出别名。 +/// +/// 抓得住:委派接错支(`Fabric` 映到 `Quilt`);有人把 `as_str` 改回手写字面量并写错; +/// 有人让 `as_str` 产出 `neo-forge`(别名只进不出);生成端 `--` 与解析端 +/// 脱节(装出来的 id 认不回同一家)。 +/// 抓不住:core 词表的字面值(那是 `game::loader` 的往返测试与 presenter 契约测试的职责)。 +#[test] +fn install_side_ids_are_delegated_to_the_core_vocabulary() { + let cases = [ + (LoaderKind::Fabric, Wide::Fabric), + (LoaderKind::Quilt, Wide::Quilt), + (LoaderKind::NeoForge, Wide::NeoForge), + (LoaderKind::Forge, Wide::Forge), + ]; + for (install, wide) in cases { + assert_eq!( + install.as_str(), + wide.as_str(), + "{install:?} 的 id 没走 core 词表" + ); + assert_ne!(install.as_str(), "neo-forge", "别名只进不出"); + assert_eq!(LoaderKind::parse(install.as_str()), Some(install), "反查"); + + let id = install.version_id("1.20.4", "0.16.5"); + let parsed = InstanceLoader::parse(&id); + assert_eq!(parsed.kind.as_str(), install.as_str(), "`{id}` 认错了家"); + assert_eq!(parsed.version, "0.16.5", "`{id}` 的版本段"); + } +} + +/// 别名策略:`neo-forge` 只作为**输入**被接受(Modrinth 的拼法),产出永远是 `neoforge`。 +/// +/// 抓得住:别名被搬进词表(`as_str` 开始产出 `neo-forge`);别名只认小写;安装侧顺手接受了 +/// 本启动器装不了的几家(原版 / OptiFine / LiteLoader)。 +/// 抓不住:`neo-forge` 之外 Modrinth 以后可能再发明的拼法——没有样本可写。 +#[test] +fn modrinth_alias_is_input_only() { + for raw in ["neo-forge", "NEO-FORGE", " neo-forge "] { + assert_eq!( + LoaderKind::parse(raw), + Some(LoaderKind::NeoForge), + "`{raw}`" + ); + } + assert_eq!( + LoaderKind::NeoForge.as_str(), + "neoforge", + "产出不许带连字符" + ); + for raw in ["vanilla", "optifine", "liteloader", "", "rift"] { + assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受"); + } +} diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index 8d7da4d..27f86b6 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -3,30 +3,36 @@ //! //! # 为什么要这一个模块 //! -//! 「这个实例装的什么加载器」在 UI 上是一句话,在代码里却有两个来源:版本 JSON 的 -//! `libraries[]`(权威但要多读一次盘)与**版本 id 的字符串**(磁贴/列表最省事的那条)。 -//! 只要有人按 id 猜就必须只有**一处**在猜——两处各算一遍迟早一边先漂,用户看到的就是 -//! 「我刚装的 Fabric 显示成原版」(M120 复审的老 bug:解析端只认 `fabric-loader-…`,认不出 -//! 自家生成的 `1.20.4-fabric-0.16.5`)。所以 presenter 的实例页、CLI 的实例总览、以后任何 -//! 「按 id 认加载器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。 -//! 生成端([`crate::addon::loader::LoaderKind::version_id`] 拼 `--`)与 -//! 解析端的一致性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住——那条测试 -//! **从生成端取 id**,不手抄。OptiFine / LiteLoader 本启动器不能装(见 -//! `presenter::host::install`),只能从别的启动器继承进来。 +//! 「这个实例装的什么加载器」在 UI 上是一句话,代码里却有两个来源:版本 JSON 的 `libraries[]` +//! (权威但要多读一次盘)与**版本 id 的字符串**(磁贴/列表最省事的那条)。只要有人按 id 猜就 +//! 必须只有**一处**在猜——两处各算一遍迟早一边先漂,用户看到的就是「我刚装的 Fabric 显示成 +//! 原版」(M120 复审的老 bug:解析端只认 `fabric-loader-…`,认不出自家生成的 +//! `1.20.4-fabric-0.16.5`)。所以 presenter 的实例页、CLI 的实例总览、以后任何「按 id 认加载 +//! 器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。生成端 +//! ([`crate::addon::loader::LoaderKind::version_id`] 拼 `--`)与解析端的一致 +//! 性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住(**从生成端取 id**,不手抄); +//! OptiFine / LiteLoader 本启动器不能装(见 `presenter::host::install`),只能继承进来。 //! -//! # 命名词表只有一份字面量(M126) +//! # 命名词表只有一张表(M126) //! //! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` / -//! `neoforge` / `optifine` / `liteloader`。其中只有 [`LoaderKind::as_str`] 是字面量;安装侧与 -//! presenter 的 `as_str` 是**委派**(结构映射,写不进串),CLI 直接调,`MARKERS` 的标记串也 -//! 取自它。加一家加载器要动: +//! `neoforge` / `optifine` / `liteloader`。生产代码里这七个串只有**一处**:下面那次 +//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来, +//! 所以「加了一支却漏进清单」**物理上做不到**(手写枚举 + 手写清单时它既不报错也不会被测试 +//! 抓住:测试同样从 `ALL` 出发,新加载器被**静默**当成「认不出」)。安装侧与 presenter 的 +//! `as_str` / `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 +//! `neo-forge`(Modrinth 拼法,只进不出)。 //! -//! 1. `LoaderKind` 加一支 → **[`LoaderKind::as_str`] / [`LoaderKind::has_marker`] / -//! presenter 的 `to_core`·`from_core` 编不过**(能装的话再加安装侧 `as_str` 一支); -//! 2. [`LoaderKind::ALL`] 加一格、`MARKERS` 加一行、尺寸锚改成 8 → **只能靠锚与测试** -//! (Rust 没有「枚举有哪几支」的编译期反射),漏了是「有 id 但认不出」,由 -//! [`tests::markers_cover_every_kind_that_has_one`] 抓; -//! 3. `ull help instances` 的词表行与 CLI 的 `json_rows_expose_loader_kind_and_version` 一起红。 +//! 加一家加载器要动:① `loader_kind_vocab!` 表里加一行(id + 认不认标记)→ 枚举 / `ALL` / +//! `as_str` / `has_marker` / 解析顺序一起长出来,漏不了;② presenter 的 `to_core`·`from_core` +//! **编不过**(逼你决定 DTO 怎么显示它),能装的话安装侧 `as_str`·`from_core` 也各差一支; +//! ③ 契约测试(presenter 的 `loader_kind_ids_are_the_released_contract`、CLI 的 +//! `json_rows_expose_loader_kind_and_version`、`ull help instances` 的词表行)**会红**——那 +//! 是逼你确认「对外词表真的要多一个 id」,因为 0.2.0 已经发出去过。 +//! +//! 分工:**改名**(`neoforge` → `neo_forge`)不会被往返测试抓住(词表与解析同源、改名自洽), +//! 只有第 ③ 步的契约测试会红——那里**故意**再抄一遍七个串,那是唯一抓得住改名的东西;往返 +//! 测试抓的是「规则把 id 认错」。 //! //! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**) //! @@ -42,78 +48,90 @@ //! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。只把加载器写进版本 JSON、id 里没痕迹的实例 //! (整包导入时被重命名成 `我的整合包` 那种)会**诚实**返回「原版」——那不等于没装加载器。 -/// 实例面向的加载器种类(比 [`crate::addon::loader::LoaderKind`] 宽)。 +/// 一张表生成整份词表:枚举、[`LoaderKind::ALL`]、`as_str`、`has_marker` 一起长出来。 /// -/// 与那个类型的区别:`addon::loader::LoaderKind` 只描述**本启动器能装**的四家(装不了就 -/// 没有下载路线,放进枚举里会逼出一堆 `unreachable!`);本类型面向**识别与展示**,所以 -/// 多出「原版」这一支,以及两家只能继承进来的第三方加载器。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum LoaderKind { - /// 原版(id 里没有任何加载器标记) - Vanilla, - /// Fabric - Fabric, - /// Quilt - Quilt, - /// Forge - Forge, - /// NeoForge - NeoForge, - /// OptiFine(第三方,本启动器不装,只继承) - OptiFine, - /// LiteLoader(第三方,本启动器不装,只继承) - LiteLoader, +/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支 +/// 却忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成 +/// 「认不出」。让两者从同一张表展开,这件事在物理上不可能发生。 +/// +/// 表里的顺序就是标记的匹配优先级;带 `marker` 的条目参与 id 识别,不带的(原版)不参与。 +macro_rules! loader_kind_vocab { + ( + $(#[$meta:meta])* + $vis:vis enum $name:ident { + $( + $(#[$variant_doc:meta])* + $variant:ident => $id:literal $(, $marker:ident)?; + )+ + } + ) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + $vis enum $name { + $( + $(#[$variant_doc])* + $variant, + )+ + } + + impl $name { + /// 全部种类,**顺序即标记的匹配优先级**;原版没有标记(由 `has_marker` 跳过)。 + pub const ALL: [Self; loader_kind_vocab!(@count $($variant)+)] = + [$(Self::$variant,)+]; + + /// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。 + /// + /// **生产代码里唯一的一份 id 字面量**(0.2.0 已发布契约);穷尽 match:加一支就编不过。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $id,)+ + } + } + + /// 这一支会不会从版本 id 的标记里认出来(原版不会)。穷尽 match:加一支必须表态。 + #[must_use] + pub const fn has_marker(self) -> bool { + match self { + $(Self::$variant => loader_kind_vocab!(@marker $($marker)?),)+ + } + } + } + }; + (@marker marker) => { true }; + (@marker) => { false }; + (@count) => { 0 }; + (@count $head:ident $($tail:ident)*) => { 1 + loader_kind_vocab!(@count $($tail)*) }; +} + +loader_kind_vocab! { + /// 实例面向的加载器种类(比 [`crate::addon::loader::LoaderKind`] 宽)。 + /// + /// 那个类型只描述**本启动器能装**的四家(装不了就没有下载路线);本类型面向**识别与展示**, + /// 多出「原版」这一支,以及两家只能继承进来的第三方加载器。 + pub enum LoaderKind { + /// 原版(id 里没有任何加载器标记) + Vanilla => "vanilla"; + /// NeoForge(必须排在 `Forge` 前:两者都是段前缀匹配,顺序写死能防以后宽成「包含」) + NeoForge => "neoforge", marker; + /// Fabric + Fabric => "fabric", marker; + /// Quilt + Quilt => "quilt", marker; + /// Forge + Forge => "forge", marker; + /// OptiFine(第三方,本启动器不装,只继承) + OptiFine => "optifine", marker; + /// LiteLoader(第三方,本启动器不装,只继承) + LiteLoader => "liteloader", marker; + } } impl LoaderKind { - /// 全部种类,**顺序即标记的匹配优先级**;原版没有标记,排最后。 + /// 稳定 id → 种类([`Self::as_str`] 的反查;整串全等、大小写不敏感、不认前缀与别名)。 /// - /// `NeoForge` 必须在 `Forge` 前(两者都是段前缀匹配,今天互不干扰,但顺序写死能防以后 - /// 有人把匹配放宽成「包含」)。这张表同时是枚举的清单,漏掉一格编译器抓不到。 - pub const ALL: [Self; 7] = [ - Self::NeoForge, - Self::Fabric, - Self::Quilt, - Self::Forge, - Self::OptiFine, - Self::LiteLoader, - Self::Vanilla, - ]; - - /// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。 - /// - /// **全仓唯一的一份 id 字面量**,也是 0.2.0 已发布契约。穷尽 match:加一支枚举就编不过。 - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Vanilla => "vanilla", - Self::Fabric => "fabric", - Self::Quilt => "quilt", - Self::Forge => "forge", - Self::NeoForge => "neoforge", - Self::OptiFine => "optifine", - Self::LiteLoader => "liteloader", - } - } - - /// 这一支会不会从版本 id 的标记里认出来(原版不会)。穷尽 match:加一支必须表态。 - #[must_use] - pub const fn has_marker(self) -> bool { - match self { - Self::Vanilla => false, - Self::Fabric - | Self::Quilt - | Self::Forge - | Self::NeoForge - | Self::OptiFine - | Self::LiteLoader => true, - } - } - - /// 稳定 id → 种类([`Self::as_str`] 的反查;整串全等,大小写不敏感,不认前缀与别名)。 - /// - /// 别和 [`InstanceLoader::parse`] 搞混:那个认的是**版本 id**(切段、认前缀、给版本段), - /// 这个认的是**词表里的一个 id**。 + /// 别和 [`InstanceLoader::parse`] 搞混:那个认的是**版本 id**(切段、认前缀、给版本段)。 + /// 别名 `neo-forge` 只有安装侧收,见 [`crate::addon::loader::LoaderKind::parse`]。 #[must_use] pub fn parse(raw: &str) -> Option { let wanted = raw.trim(); @@ -123,24 +141,6 @@ impl LoaderKind { } } -/// 词表尺寸是契约的一部分:加一支枚举并放进 [`LoaderKind::ALL`] 时这行会红,逼作者确认 -/// 「对外词表真的要多一个 id 了」。 -const _: () = assert!(LoaderKind::ALL.len() == 7); - -/// 标记 → 种类,**顺序即优先级**(同一段里先命中的算数)。 -/// -/// 标记串取自 [`LoaderKind::as_str`](没有第二份字面量);成员与 -/// [`LoaderKind::has_marker`] 是否一致由 [`tests::markers_cover_every_kind_that_has_one`] -/// 钉住——加一支枚举而漏加一行,那条会红。 -const MARKERS: [(LoaderKind, &'static str); 6] = [ - (LoaderKind::NeoForge, LoaderKind::NeoForge.as_str()), - (LoaderKind::Fabric, LoaderKind::Fabric.as_str()), - (LoaderKind::Quilt, LoaderKind::Quilt.as_str()), - (LoaderKind::Forge, LoaderKind::Forge.as_str()), - (LoaderKind::OptiFine, LoaderKind::OptiFine.as_str()), - (LoaderKind::LiteLoader, LoaderKind::LiteLoader.as_str()), -]; - /// 一个版本 id 解析出来的加载器:种类 + 版本段(原版为空串)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstanceLoader { @@ -186,12 +186,14 @@ impl InstanceLoader { } /// 这一段的**前缀**是哪个加载器标记;命中时连它后面剩下的部分一起给出。 +/// +/// 标记串 = [`LoaderKind::as_str`],顺序 = [`LoaderKind::ALL`](即优先级),认不认标记由 +/// [`LoaderKind::has_marker`] 表决:**解析表没有独立字面量,也没有独立清单**。 fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> { - for (kind, marker) in MARKERS { - let Some(rest) = strip_prefix_ascii_ci(segment, marker) else { - continue; - }; - return Some((kind, strip_loader_suffix(rest))); + for kind in LoaderKind::ALL.into_iter().filter(|kind| kind.has_marker()) { + if let Some(rest) = strip_prefix_ascii_ci(segment, kind.as_str()) { + return Some((kind, strip_loader_suffix(rest))); + } } None } @@ -371,29 +373,26 @@ mod tests { } } - /// 词表 ↔ 解析表:`MARKERS` 必须与 [`LoaderKind::ALL`] / [`has_marker`] / [`as_str`] - /// 逐格对齐,并且按标记串能 round-trip 回同一支。 + /// 往返锚:**每个对外 id 喂回解析都要认成它自己**,标记顺序也得留优先级。 /// - /// 抓得住:加一支枚举漏加 `MARKERS` 一行(`Vec` 逐项比对,顺序也算,所以 `NeoForge` - /// 被排到 `Forge` 后面同样红);`MARKERS` 被改回手写字面量并写错串。抓不住:词表字面值 - /// 本身(改名时两侧同源不会红)——那是对外契约测试的职责,见 - /// `cli::commands::instances::tests::json_rows_expose_loader_kind_and_version`。 - /// - /// [`has_marker`]: LoaderKind::has_marker - /// [`as_str`]: LoaderKind::as_str + /// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏);标记串与 + /// 词表脱节;`neoforge` 被排到 `forge` 后面。 + /// 抓不住:**改名**(`neoforge` → `neo_forge`)——词表与解析同源、改名自洽,本条是绿的; + /// 抓改名是对外契约测试的职责(`cli::commands::instances::tests:: + /// json_rows_expose_loader_kind_and_version`)。 #[test] - fn markers_cover_every_kind_that_has_one() { - let listed: Vec = MARKERS.iter().map(|(kind, _)| *kind).collect(); - let expected: Vec = LoaderKind::ALL - .into_iter() - .filter(|kind| kind.has_marker()) - .collect(); - assert_eq!(listed, expected, "`MARKERS` 必须正好是「认标记」的那几支"); - for (kind, marker) in MARKERS { - assert_eq!(marker, kind.as_str(), "标记串必须就是稳定 id"); - let parsed = InstanceLoader::parse(&format!("1.20.4-{marker}-0.16.5")); - assert_eq!(parsed.kind, kind, "标记 `{marker}` 的版本 id"); - assert_eq!(parsed.version, "0.16.5", "标记 `{marker}` 的版本段"); + fn every_stable_id_parses_back_to_its_kind() { + for kind in LoaderKind::ALL { + let id = kind.as_str(); + assert_eq!(LoaderKind::parse(id), Some(kind), "反查 `{id}`"); + let parsed = InstanceLoader::parse(&format!("1.20.4-{id}-0.16.5")); + assert_eq!(parsed.kind, kind, "`{id}` 认成了 {:?}", parsed.kind); + let expected = if kind.has_marker() { "0.16.5" } else { "" }; + assert_eq!(parsed.version, expected, "`{id}` 的版本段"); } + let order: Vec = LoaderKind::ALL.into_iter().collect(); + let neoforge = order.iter().position(|kind| *kind == LoaderKind::NeoForge); + let forge = order.iter().position(|kind| *kind == LoaderKind::Forge); + assert!(neoforge < forge, "`neoforge` 要在 `forge` 前:{order:?}"); } } From 14ac0dc2487968362b2ed177c14003fd5ebd525d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 17:33:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(m126=20r2):=20=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E6=94=AF=E6=9E=9A=E4=B8=BE=E4=B8=8D=E6=9B=B4=E6=96=B0=E5=AF=B9?= =?UTF-8?q?=E5=A4=96=E5=A5=91=E7=BA=A6=E7=8E=B0=E5=9C=A8=E5=BF=85=E7=84=B6?= =?UTF-8?q?=E7=BA=A2=EF=BC=88=E9=9B=86=E5=90=88=E6=96=AD=E8=A8=80=20+=20he?= =?UTF-8?q?lp=20=E5=AE=88=E5=8D=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_*` 仍绿(它们本就管别的事)。 --- crates/cli/src/commands/instances.rs | 28 +++++++++++ .../core/src/addon/loader/tests/vocabulary.rs | 21 ++++++++ crates/core/src/game/loader.rs | 49 +++++++++---------- crates/presenter/src/dto/instances.rs | 38 ++++++++++---- 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index 023d3f9..5e77503 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -144,3 +144,31 @@ fn loader_json(version_id: &str) -> Value { #[cfg(test)] mod tests; + +/// `ull help instances` 的词表行是**对外文档契约**,不许成为第二个说假话的地方(r2)。 +/// +/// 单列在这里而不是塞进 `instances/tests.rs`:那几个断言钉的是本模块文档承诺的词表,与命令 +/// 行为无关(`tests.rs` 的职责是「跑真入口看输出」),而且这样 help 的契约与被承诺的那段 +/// 文档就在同一个文件里。 +#[cfg(test)] +mod help_vocabulary_tests { + use unlucky_core::game::LoaderKind; + + /// help 的词表必须覆盖**每一支**稳定 id(含原版与两家只继承、不安装的)。 + /// + /// 抓得住:help 的词表行被改字或删项(`neoforge` → `neo_forge`);**新增一家加载器而忘了 + /// 更新 help**——下面按 [`LoaderKind::ALL`] 遍历而不是手抄七项,所以新分支漏写必红。 + /// 抓不住:措辞与排版(只做子串断言);「kind 是识别口径不是安装口径」那段语义说明是否 + /// 仍然准确,只能靠人读。 + #[test] + fn help_lists_every_stable_id() { + let topic = crate::cli::help::topic("instances").expect("help instances 存在"); + for kind in LoaderKind::ALL { + assert!( + topic.contains(kind.as_str()), + "help instances 的词表行漏了 `{}`:\n{topic}", + kind.as_str() + ); + } + } +} diff --git a/crates/core/src/addon/loader/tests/vocabulary.rs b/crates/core/src/addon/loader/tests/vocabulary.rs index b6e0458..59b6664 100644 --- a/crates/core/src/addon/loader/tests/vocabulary.rs +++ b/crates/core/src/addon/loader/tests/vocabulary.rs @@ -61,3 +61,24 @@ fn modrinth_alias_is_input_only() { assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受"); } } + +/// [`LoaderKind::all`] 是安装侧唯一还手写的清单(CF id 前缀匹配、CLI `--loader` 提示用), +/// 它与 [`LoaderKind::from_core`] 认可的集合必须一致——不重抄字面量,直接对映射本身。 +/// +/// 抓得住:往 core 加一家**能装**的加载器,`from_core` 补了却忘了 `all()`(CLI 提示会漏一家、 +/// CF id 不认识它的前缀);反过来 `all()` 里多一家而 `from_core` 说装不了。 +/// 抓不住:`all()` 的顺序(那是 CLI 提示的展示序,没有对外契约意义)。 +#[test] +fn installable_list_matches_the_from_core_mapping() { + let mut derived: Vec = Wide::ALL + .into_iter() + .filter_map(LoaderKind::from_core) + .collect(); + derived.sort_unstable(); + let mut listed = LoaderKind::all().to_vec(); + listed.sort_unstable(); + assert_eq!( + listed, derived, + "`LoaderKind::all()` 必须正好是 `from_core` 认可的那几家" + ); +} diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index 27f86b6..da9d86c 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -17,29 +17,29 @@ //! //! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` / //! `neoforge` / `optifine` / `liteloader`。生产代码里这七个串只有**一处**:下面那次 -//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来, -//! 所以「加了一支却漏进清单」**物理上做不到**(手写枚举 + 手写清单时它既不报错也不会被测试 -//! 抓住:测试同样从 `ALL` 出发,新加载器被**静默**当成「认不出」)。安装侧与 presenter 的 -//! `as_str` / `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 -//! `neo-forge`(Modrinth 拼法,只进不出)。 +//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来,所以 +//! 「加了一支却漏进清单」物理上做不到(理由见宏的注释)。安装侧与 presenter 的 `as_str` / +//! `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 `neo-forge` +//! (Modrinth 拼法,只进不出)。 //! //! 加一家加载器要动:① `loader_kind_vocab!` 表里加一行(id + 认不认标记)→ 枚举 / `ALL` / //! `as_str` / `has_marker` / 解析顺序一起长出来,漏不了;② presenter 的 `to_core`·`from_core` //! **编不过**(逼你决定 DTO 怎么显示它),能装的话安装侧 `as_str`·`from_core` 也各差一支; -//! ③ 契约测试(presenter 的 `loader_kind_ids_are_the_released_contract`、CLI 的 -//! `json_rows_expose_loader_kind_and_version`、`ull help instances` 的词表行)**会红**——那 -//! 是逼你确认「对外词表真的要多一个 id」,因为 0.2.0 已经发出去过。 +//! ③ 契约测试**会红**:presenter 的 `loader_kind_ids_are_the_released_contract` 把 core 的 +//! `ALL` 与独立手写的七个 id 做**集合比较**,CLI 的 `help_lists_every_stable_id` 按 `ALL` +//! 遍历 help 词表,`json_rows_*` 钉真二进制输出面——三处都逼你确认「对外词表真的要多一个 +//! id」,因为 0.2.0 已经发出去过。 //! -//! 分工:**改名**(`neoforge` → `neo_forge`)不会被往返测试抓住(词表与解析同源、改名自洽), -//! 只有第 ③ 步的契约测试会红——那里**故意**再抄一遍七个串,那是唯一抓得住改名的东西;往返 -//! 测试抓的是「规则把 id 认错」。 +//! ⚠️ 分工要看准:**编译器只拦「漏 match」**。新种类在三处消费方用最省事的 `=> Vanilla` 糊 +//! 过去是**编得过**的(那时它会被静默当成原版);真正拦住这件事的是 ③ 里那条集合断言,不是 +//! 编译器。**改名**(`neoforge` → `neo_forge`)同理:往返测试是绿的(词表与解析同源、改名自 +//! 洽),只有 ③ 会红——那里**故意**再抄一遍七个串。 //! //! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**) //! //! 1. 把 id 按 `-` 切段,从左往右找第一段,它的**前缀**是某个加载器标记 -//! (`neoforge` / `fabric` / `quilt` / `forge` / `optifine` / `liteloader`); -//! 标记后面紧跟的 `loader` 段(`fabric-loader-…`)、或同一段里的 `_loader` 尾巴, -//! 都算标记的一部分而不是版本段; +//! (`neoforge` / `fabric` / `quilt` / `forge` / `optifine` / `liteloader`);紧跟的 `loader` +//! 段(`fabric-loader-…`)或同段里的 `_loader` 尾巴算标记的一部分,不是版本段; //! 2. 该段里标记之后剩下的部分,加上它右边的所有段,按 `-` 拼回版本段 //! (`_` 之类的原样保留,所以 `1.13.2-OptiFine_HD_U_E6` 的版本是 `HD_U_E6`); //! 3. 例外:版本段有 **≥2 段** 且**最后一段**长得像 MC 版本(`1.20.1` 这种纯数字点号)时 @@ -50,11 +50,10 @@ /// 一张表生成整份词表:枚举、[`LoaderKind::ALL`]、`as_str`、`has_marker` 一起长出来。 /// -/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支 -/// 却忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成 -/// 「认不出」。让两者从同一张表展开,这件事在物理上不可能发生。 -/// -/// 表里的顺序就是标记的匹配优先级;带 `marker` 的条目参与 id 识别,不带的(原版)不参与。 +/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支却 +/// 忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成「认不 +/// 出」;让两者从同一张表展开,这件事在物理上不可能发生。表里的顺序就是标记的匹配优先级; +/// 带 `marker` 的条目参与 id 识别,不带的(原版)不参与。 macro_rules! loader_kind_vocab { ( $(#[$meta:meta])* @@ -231,8 +230,8 @@ fn join_version(parts: &[&str]) -> String { kept.join("-") } -/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号)。只用于「砍 HMCL 尾巴」,那一条 -/// 要求前面还有内容(`≥2` 段),所以单独的 `0.16.5` 不会被砍,`0.20.0-beta.9` 也不会。 +/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号);只用于「砍 HMCL 尾巴」(要求前面 +/// 还有内容,所以 `0.16.5` 与 `0.20.0-beta.9` 都不受影响)。 fn looks_like_mc_version(text: &str) -> bool { text.contains('.') && text @@ -375,11 +374,9 @@ mod tests { /// 往返锚:**每个对外 id 喂回解析都要认成它自己**,标记顺序也得留优先级。 /// - /// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏);标记串与 - /// 词表脱节;`neoforge` 被排到 `forge` 后面。 - /// 抓不住:**改名**(`neoforge` → `neo_forge`)——词表与解析同源、改名自洽,本条是绿的; - /// 抓改名是对外契约测试的职责(`cli::commands::instances::tests:: - /// json_rows_expose_loader_kind_and_version`)。 + /// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏)、标记串与词表 + /// 脱节、`neoforge` 被排到 `forge` 后面。抓不住:**改名**——词表与解析同源、改名自洽,本条 + /// 是绿的;抓改名是 presenter 的 `loader_kind_ids_are_the_released_contract`。 #[test] fn every_stable_id_parses_back_to_its_kind() { for kind in LoaderKind::ALL { diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 4e72e31..341ed14 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -281,14 +281,19 @@ mod tests { assert_eq!(LoaderKind::parse("unknown"), None); } - /// DTO 的 `as_str` / `parse` 是 core 词表的**委派**(M126),而词表本身是**对外契约**: - /// 七个 id 在这里独立抄一遍(0.2.0 已发布形状,改名等于违约)。 + /// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做 + /// **集合比较**——这是「加了一支枚举却没人更新对外词表」的唯一守卫。 /// - /// 抓得住:core 的 `as_str` 或委派接线改错(`OptiFine` 映到 `LiteLoader`、映射漏了哪支); - /// 词表改字/改大小写(`neoforge` → `neo_forge`);`parse` 收进了不该有的别名(`neo-forge`)。 - /// 抓不住:core 的 `ALL` / `MARKERS` 之间对不对得上(那是 core 的 - /// `markers_cover_every_kind_that_has_one` 的职责),以及真二进制输出面上的 JSON - /// (那是 CLI 的 `json_rows_expose_loader_kind_and_version`)。 + /// 为什么不是编译器管:编译期只拦得到「漏 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] = [ @@ -300,12 +305,28 @@ mod tests { (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!( @@ -314,8 +335,7 @@ mod tests { "`{alias}`" ); } - assert_eq!(LoaderKind::parse("neo-forge"), None); - assert_eq!(LoaderKind::parse("rift"), None); + assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧"); } #[test] From f6b7759aa81bb0c5cecf0dea456579d26fafd121 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 18:08:11 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(m126=20r3):=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E3=80=8C=E5=94=AF=E4=B8=80=E4=B8=80=E4=BB=BD=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E8=AF=B4=E6=B3=95=EF=BC=8C=E5=B9=B6=E6=8A=8A=20=E2=91=A2=20?= =?UTF-8?q?=E7=9A=84=E6=89=BF=E8=AF=BA=E6=94=B9=E6=88=90=E7=9C=9F=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯文档,零逻辑改动(`git diff` 里每一行都是 `//!` / `///`)。 P2-1「唯一一份」不成立:`cli/src/cli/help.rs` 里 `ull help instances` 的词表行是 **会打印给用户看的第二份逐字副本**。四处说法收窄为「源头表在 core + 另一处逐字副本在 help.rs,由 `help_lists_every_stable_id` 按 `ALL` 遍历守着」:`game/loader.rs` 的模块 文档(原「生产代码里这七个串只有一处」)与 `as_str` doc(原「生产代码里唯一一份 id 字面量」)、`cli/commands/instances.rs` 的模块文档(原「全仓唯一一份 id 字面量」)与 `loader_json` doc(原「两样都只该有一份」)、`presenter/dto/instances.rs` 的 as_str doc 与契约测试 doc、`addon/loader/mod.rs` 的两处「唯一真相源」。 P2-2「③ 拦得住静默变原版」对显示路径不成立:`presenter/src/host/game.rs::dto_kind` 里 糊一支 `=> Vanilla`、同时把契约与 help 补齐,三个 crate 全绿而磁贴静默显示「原版」。 现在写的是「③ 拦的是**对外词表**(加种类、改词表都红),但**拦不住显示路径**,那处目前 没有守卫(已知缺口,另行记账)」。 顺带只做排版级的行数回收:loader.rs 400 → 397(≤400,仍留余量)。 门禁:cargo fmt --all --check → FMT_OK(本轮按要求只跑 fmt)。 --- crates/cli/src/commands/instances.rs | 14 +++++----- crates/core/src/addon/loader/mod.rs | 6 ++--- crates/core/src/game/loader.rs | 38 ++++++++++++++------------- crates/presenter/src/dto/instances.rs | 7 ++--- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index 5e77503..857ea95 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -8,10 +8,12 @@ //! //! 每一行实例带一个 `loader` 对象:`{"kind": <稳定 id>, "version": <版本段>}`。它是 //! **`version_id` 的解析结果**,解析本身不在这里——[`unlucky_core::game::InstanceLoader::parse`] -//! 是全仓唯一的 id → 加载器入口(M122 收敛)。种类 → 稳定 id 的词表同样不在本 crate: -//! [`unlucky_core::game::LoaderKind::as_str`] 是全仓唯一一份 id 字面量(M126 收敛),这里直接 -//! 调它,**连翻译层都没有**——词表与 id 匹配一样,两处各算一遍就会漂(M120 的「刚装的 -//! Fabric 显示成原版」)。 +//! 是全仓唯一的 id → 加载器入口(M122 收敛)。种类 → 稳定 id 的词表也不在本 crate 的代码里: +//! [`unlucky_core::game::LoaderKind::as_str`] 是生产代码里 id 的**源头表**(M126 收敛),这里 +//! 直接调它,**连翻译层都没有**——词表与 id 匹配一样,两处各算一遍就会漂(M120 的「刚装的 +//! Fabric 显示成原版」)。生产代码里唯一的逐字副本是 `cli::help` 里 `ull help instances` 的 +//! 词表行(会打印给用户),由本模块的 `help_vocabulary_tests::help_lists_every_stable_id` +//! 按 `ALL` 遍历守着。 //! //! ## 约定一:`loader` 恒存在,原版是 `{"kind":"vanilla","version":""}` //! @@ -131,8 +133,8 @@ fn option_path(path: Option<&PathBuf>) -> Option { /// 版本 id → `loader` 对象(`{"kind": …, "version": …}`)。 /// /// 解析走 [`InstanceLoader::parse`](全仓唯一入口);种类 → 稳定 id 走 -/// [`unlucky_core::game::LoaderKind::as_str`](词表的唯一真相源,M126)。这里既**不写 id -/// 匹配**,也**不写词表**:两样都只该有一份,且都在 core。原版同样给出对象 +/// [`unlucky_core::game::LoaderKind::as_str`](词表的源头,M126)。这里既**不写 id +/// 匹配**,也**不写词表**:词表的源头只在 core(副本见模块文档)。原版同样给出对象 /// (`kind = vanilla`、`version = ""`),不缺键、不为 `null`——见模块文档的「约定一」。 fn loader_json(version_id: &str) -> Value { let parsed = InstanceLoader::parse(version_id); diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs index a827521..a915f2d 100644 --- a/crates/core/src/addon/loader/mod.rs +++ b/crates/core/src/addon/loader/mod.rs @@ -64,7 +64,7 @@ pub enum LoaderKind { impl LoaderKind { /// 解析命令行 / 整合包清单里的加载器名(大小写不敏感)。 /// - /// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表唯一真相源,M126), + /// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126), /// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack /// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出 /// 它。识别得出但本启动器装不了的几家(原版 / OptiFine / LiteLoader)返回 `None`。 @@ -94,8 +94,8 @@ impl LoaderKind { /// 稳定字符串 id(进版本 id、JSON 输出与日志)。 /// - /// 词表**不在本模块**:[`crate::game::LoaderKind::as_str`] 是「种类 → 对外稳定 id」的唯一 - /// 真相源(M126)。这里只做**结构映射**(一个 id 字面量都没有)——安装侧拼出来的 + /// 词表**不在本模块**:[`crate::game::LoaderKind::as_str`] 是「种类 → 对外稳定 id」的源头 + /// (M126)。这里只做**结构映射**(一个 id 字面量都没有)——安装侧拼出来的 /// `--` 必须能被 [`crate::game::InstanceLoader::parse`] 认回来,委派让 /// 「两边各写一份、改一边忘了另一边」这件事物理上做不到。 #[must_use] diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index da9d86c..490a6d2 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -1,5 +1,5 @@ -//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122),外加「种类 → 对外稳定 id」的 -//! **全仓唯一**命名词表(M126,见 [`LoaderKind::as_str`])。 +//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122),外加「种类 → 对外稳定 id」的命名词表 +//! **源头**(M126,见 [`LoaderKind::as_str`];副本只有一处,见下)。 //! //! # 为什么要这一个模块 //! @@ -16,24 +16,25 @@ //! # 命名词表只有一张表(M126) //! //! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` / -//! `neoforge` / `optifine` / `liteloader`。生产代码里这七个串只有**一处**:下面那次 +//! `neoforge` / `optifine` / `liteloader`。**生产代码里这份词表有两处,都要改**:① 下面那次 //! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来,所以 -//! 「加了一支却漏进清单」物理上做不到(理由见宏的注释)。安装侧与 presenter 的 `as_str` / -//! `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 `neo-forge` -//! (Modrinth 拼法,只进不出)。 +//! 「加了一支却漏进清单」物理上做不到(理由见宏的注释);② 会打印给用户看的 +//! `crates/cli/src/cli/help.rs` 里 `ull help instances` 的词表行——**逐字副本**、没有委派, +//! 由 CLI 的 `help_lists_every_stable_id`(按 `ALL` 遍历)守着。安装侧与 presenter 的 `as_str` / +//! `parse` 都是**委派**,CLI 的 `loader.kind` 直接调;唯一不进词表的字面量是安装侧收的输入别名 +//! `neo-forge`(Modrinth 拼法,只进不出)。 //! //! 加一家加载器要动:① `loader_kind_vocab!` 表里加一行(id + 认不认标记)→ 枚举 / `ALL` / //! `as_str` / `has_marker` / 解析顺序一起长出来,漏不了;② presenter 的 `to_core`·`from_core` //! **编不过**(逼你决定 DTO 怎么显示它),能装的话安装侧 `as_str`·`from_core` 也各差一支; -//! ③ 契约测试**会红**:presenter 的 `loader_kind_ids_are_the_released_contract` 把 core 的 -//! `ALL` 与独立手写的七个 id 做**集合比较**,CLI 的 `help_lists_every_stable_id` 按 `ALL` -//! 遍历 help 词表,`json_rows_*` 钉真二进制输出面——三处都逼你确认「对外词表真的要多一个 -//! id」,因为 0.2.0 已经发出去过。 +//! ③ 契约测试**会红**:presenter 的 `loader_kind_ids_are_the_released_contract` 拿 core 的 `ALL` +//! 与独立手写的七个 id 做**集合比较**,CLI 的 `help_lists_every_stable_id` 按 `ALL` 遍历 help +//! 词表,`json_rows_*` 钉真二进制输出面(0.2.0 已发出去过,所以这步是确认而不是可选)。 //! -//! ⚠️ 分工要看准:**编译器只拦「漏 match」**。新种类在三处消费方用最省事的 `=> Vanilla` 糊 -//! 过去是**编得过**的(那时它会被静默当成原版);真正拦住这件事的是 ③ 里那条集合断言,不是 -//! 编译器。**改名**(`neoforge` → `neo_forge`)同理:往返测试是绿的(词表与解析同源、改名自 -//! 洽),只有 ③ 会红——那里**故意**再抄一遍七个串。 +//! ⚠️ 分工要看准:**编译器只拦「漏 match」**,新种类在消费方用 `=> Vanilla` 糊过去编得过。 +//! ③ 那条集合断言拦的是**对外词表**,但**拦不住显示路径**:`presenter/src/host/game.rs::dto_kind` +//! 里糊一支 `=> Vanilla` 并把契约与 help 补齐后,三个 crate 全绿而磁贴静默显示「原版」——那处 +//! 目前**没有守卫**(已知缺口,另行记账)。**改名**同理:往返测试绿,只有 ③ 会红(故意再抄)。 //! //! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**) //! @@ -42,9 +43,8 @@ //! 段(`fabric-loader-…`)或同段里的 `_loader` 尾巴算标记的一部分,不是版本段; //! 2. 该段里标记之后剩下的部分,加上它右边的所有段,按 `-` 拼回版本段 //! (`_` 之类的原样保留,所以 `1.13.2-OptiFine_HD_U_E6` 的版本是 `HD_U_E6`); -//! 3. 例外:版本段有 **≥2 段** 且**最后一段**长得像 MC 版本(`1.20.1` 这种纯数字点号)时 -//! 砍掉最后一段——那是老 HMCL 的 `fabric-loader--` 写法。一段都不剩的 -//! `0.16.5` 不会被砍;`0.20.0-beta.9` 的末段不是数字,也不会被砍; +//! 3. 例外:版本段 **≥2 段**且末段像 MC 版本(`1.20.1` 这种纯数字点号)时砍掉末段——老 HMCL 的 +//! `fabric-loader--` 写法;`0.16.5`(只剩一段)与 `0.20.0-beta.9` 不受影响; //! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。只把加载器写进版本 JSON、id 里没痕迹的实例 //! (整包导入时被重命名成 `我的整合包` 那种)会**诚实**返回「原版」——那不等于没装加载器。 @@ -80,7 +80,9 @@ macro_rules! loader_kind_vocab { /// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。 /// - /// **生产代码里唯一的一份 id 字面量**(0.2.0 已发布契约);穷尽 match:加一支就编不过。 + /// 这是**生产代码里 id 的源头表**(0.2.0 已发布契约):别处要么委派到这里,要么是逐字 + /// 副本(`ull help instances` 的词表行,由 CLI 的 `help_lists_every_stable_id` 守着)。 + /// 穷尽 match:加一支就编不过。 #[must_use] pub const fn as_str(self) -> &'static str { match self { diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 341ed14..daf606d 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -76,8 +76,8 @@ impl LoaderKind { /// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。 /// - /// **委派**给 [`CoreLoaderKind::as_str`](M126):整张词表在 core 只有一份字面量 - /// (0.2.0 已发布契约),这里只剩一次结构映射。 + /// **委派**给 [`CoreLoaderKind::as_str`](M126):词表的源头表在 core(0.2.0 已发布契约; + /// 另一处逐字副本是 `ull help instances` 的词表行),这里只剩一次结构映射。 #[must_use] pub fn as_str(self) -> &'static str { self.to_core().as_str() @@ -282,7 +282,8 @@ mod tests { } /// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做 - /// **集合比较**——这是「加了一支枚举却没人更新对外词表」的唯一守卫。 + /// **集合比较**——这是对外词表的守卫(与 CLI 的 help 守卫互补;显示路径 + /// `presenter::host::game::dto_kind` 目前没有守卫,见 `game::loader` 的模块文档)。 /// /// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在三处消费方用最省事的 /// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档