feat(presenter): 导入已有版本的 DTO 与三条后台线程(M223)

core 的能力(M221)接进 presenter 的 IPC 面:

- dto/adopt.rs:AdoptCommand / AdoptEvent / 六种视图 / AdoptState + AdoptStage。
  全部 Serialize+Deserialize,**不 import 任何 core 类型**(契约 §5.4);
- host/adopt.rs:spawn_scan(adopt::scan)/ spawn_adopt(plan 或 adopt),结果经
  HostTask::Adopt 回流;core 的候选/计划/结果/失败逐字段搬进 DTO,problems[] 一条不少;
- CoreHost 加 adopt_epoch(每条命令 +1、关面板也 +1),连点与迟到的回程不会点亮面板;
- 登记成功后补一次 refresh_instances(新实例这一拍就出现在列表里)。

运行目录只从 plan()/adopt() 的返回值取(缺省档可能带 -2 后缀,只有 core 知道用了哪个);
target_notes() 的原文随 AdoptPlanView::notes 上屏,界面不另写一份后果话术。
This commit is contained in:
AskaEth committed 2026-11-10 19:37:23 +08:00
1 parent 9ab909d01c
commit 26e4fbb91d
6 files changed
+1012

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。
//!
//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见
//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**,
//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。
//!
//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置
//!
//! | 段 | 指令 | 回程 | 写盘 |
//! |---|---|---|---|
//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) |
//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 |
//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) |
//!
//! ## 三条硬纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。
//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道
//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。
//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句,
//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的
//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。
//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。
//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败
//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。
//!
//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated`
//!
//! 全局缺省 `isolated` 的运行目录就是 `versions/<id>/`——那是**别人的版本目录**。沿用它会让第一次
//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的
//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// app → host 的导入意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptCommand {
/// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁)
Scan,
/// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。
Preview(Box<AdoptRequestView>),
/// 确认登记(唯一写盘入口)。
Confirm(Box<AdoptRequestView>),
/// 关掉面板(扫描结果 / 预检 / 结果卡都收起来)
Dismiss,
}
/// host → app 的回程事件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptEvent {
/// 扫描结果(`versions/` 里的全部条目,逐项带问题)
Scan(Box<AdoptScanView>),
/// 预检结果(落点 + 后果原文 + 将写的展示名)
Planned(Box<AdoptPlanView>),
/// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空)
Failed(Box<AdoptFailureView>),
/// 登记完成
Done(Box<AdoptDoneView>),
/// 面板已关(状态复位)
Cleared,
}
/// 登记后的**运行目录从哪来**(隔离档由它决定)。
///
/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。
/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AdoptTargetView {
/// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离)
#[default]
InstanceDir,
/// 共用游戏根(别人的 `.minecraft`)——必须明确点选
Shared,
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据
VersionDir,
}
impl AdoptTargetView {
/// 三档的声明顺序(= 面板上的先后,缺省在最前)。
pub const ALL: [AdoptTargetView; 3] = [
AdoptTargetView::InstanceDir,
AdoptTargetView::Shared,
AdoptTargetView::VersionDir,
];
/// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::InstanceDir => "instance_dir",
Self::Shared => "shared",
Self::VersionDir => "version_dir",
}
}
/// 从 slug 反解。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|target| target.slug() == slug)
}
/// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。
#[must_use]
pub const fn touches_foreign_dir(self) -> bool {
matches!(self, Self::Shared | Self::VersionDir)
}
}
/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptIsolationView {
/// `versions/<id>/` 当运行目录
Isolated,
/// 游戏根当运行目录
Shared,
/// 指定/新建目录当运行目录(缺省档就是这一档)
Custom,
}
impl AdoptIsolationView {
/// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Isolated => "isolated",
Self::Shared => "shared",
Self::Custom => "custom",
}
}
/// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
match slug {
"isolated" => Some(Self::Isolated),
"shared" => Some(Self::Shared),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
/// 一次预检/登记的请求(页面表单 → host)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptRequestView {
/// 要接进来的版本 id(= `versions/<id>/` 的目录名)
pub version_id: String,
/// 展示名(`None` / 空串 = 用版本 id)
pub name: Option<String>,
/// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`])
pub target: AdoptTargetView,
}
impl AdoptRequestView {
/// 最小请求(缺省名 + 缺省运行目录)。
#[must_use]
pub fn new(version_id: impl Into<String>) -> Self {
Self {
version_id: version_id.into(),
name: None,
target: AdoptTargetView::default(),
}
}
}
/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptProblemView {
/// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本)
pub message: String,
}
/// 盘上的一个候选版本。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptCandidateView {
/// 版本 id(= 目录名,登记后写进 `profiles.json`)
pub version_id: String,
/// 客户端 jar 在不在(只报事实,不校验内容)
pub jar_present: bool,
/// `inheritsFrom` 链(自身在前、最祖先在后)
pub inherits: Vec<String>,
/// 已经绑定这个版本的实例名(空 = 还没登记过)
pub registered: Vec<String>,
/// 问题清单(空 = 可登记)
pub problems: Vec<AdoptProblemView>,
}
impl AdoptCandidateView {
/// 能不能登记(没有任何问题)。
#[must_use]
pub fn adoptable(&self) -> bool {
self.problems.is_empty()
}
/// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。
#[must_use]
pub fn is_registered(&self) -> bool {
!self.registered.is_empty()
}
}
/// 一次扫描的结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptScanView {
/// 被扫的目录(`<游戏根>/versions`)
pub versions_dir: PathBuf,
/// 候选(按版本 id 字典序)
pub candidates: Vec<AdoptCandidateView>,
}
impl AdoptScanView {
/// 候选条数。
#[must_use]
pub fn total(&self) -> usize {
self.candidates.len()
}
/// 可登记的条数。
#[must_use]
pub fn adoptable(&self) -> usize {
self.candidates
.iter()
.filter(|candidate| candidate.adoptable())
.count()
}
/// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptPlanView {
/// 版本 id
pub version_id: String,
/// 将写进档案的展示名(空名已回落到版本 id)
pub name: String,
/// 运行目录选择
pub target: AdoptTargetView,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生)
pub run_dir: PathBuf,
/// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录)
pub notes: Vec<String>,
}
/// 登记完成。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptDoneView {
/// 实例 id(稳定 UUID)
pub instance_id: String,
/// 展示名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// 生效运行目录(**唯一来源**:启动时用的就是它)
pub run_dir: PathBuf,
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)
pub dir_created: bool,
/// 人读提示(与预检同一份)
pub notes: Vec<String>,
}
/// 一次失败(整次调用,不是单条候选问题)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptFailureView {
/// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文)
pub message: String,
/// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空)
pub problems: Vec<AdoptProblemView>,
}
/// 向导的**事件态**(`AppState::adopt` 的全部内容)。
///
/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段
/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**——
/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AdoptState {
/// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉)
pub scan: Option<AdoptScanView>,
/// 最近一次预检(`None` = 还没预检 / 已过期)
pub plan: Option<AdoptPlanView>,
/// 最近一次失败
pub failure: Option<AdoptFailureView>,
/// 最近一次完成的登记
pub done: Option<AdoptDoneView>,
}
impl AdoptState {
/// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。
pub fn apply(&mut self, event: AdoptEvent) {
match event {
AdoptEvent::Scan(scan) => {
// 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了)
self.scan = Some(*scan);
self.plan = None;
self.failure = None;
self.done = None;
}
AdoptEvent::Planned(plan) => {
self.plan = Some(*plan);
self.failure = None;
self.done = None;
}
AdoptEvent::Failed(failure) => {
// 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着
self.plan = None;
self.failure = Some(*failure);
}
AdoptEvent::Done(done) => {
// 登记成功:预检与失败都过期(这一轮已经有结论)
self.plan = None;
self.failure = None;
self.done = Some(*done);
}
AdoptEvent::Cleared => {
self.scan = None;
self.plan = None;
self.failure = None;
self.done = None;
}
}
}
/// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。
///
/// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍,
/// 是为了「同一时刻只画一段」在单测里可断言。
#[must_use]
pub fn stage(&self) -> AdoptStage {
if self.done.is_some() {
return AdoptStage::Done;
}
if self.failure.is_some() {
return AdoptStage::Failed;
}
if self.plan.is_some() {
return AdoptStage::Preview;
}
AdoptStage::Pick
}
}
/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptStage {
/// 扫出来的候选列表(选版本)
Pick,
/// 预检结果(落点 + 后果原文 + 确认按钮)
Preview,
/// 失败(原因逐条)
Failed,
/// 完成
Done,
}
impl AdoptStage {
/// 稳定 slug(进日志/测试断言用)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Pick => "pick",
Self::Preview => "preview",
Self::Failed => "failed",
Self::Done => "done",
}
}
}
#[cfg(test)]
mod tests;
+136
View File
@@ -0,0 +1,136 @@
//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。
//!
//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与
//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs`
//! 文件,`mod tests;` 的落点就是本文件。
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: vec![
candidate("1.20.1", Vec::new()),
candidate(
"1.19.4",
vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "版本 1.19.4 没有清单文件".to_string(),
}],
),
],
}
}
#[test]
fn events_round_trip_through_json() {
let event = AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "清单缺了".to_string(),
}],
}));
let text = serde_json::to_string(&event).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptEvent>(&text).expect("反序列化"),
event
);
let command = AdoptCommand::Preview(Box::new(AdoptRequestView {
version_id: "1.20.1".to_string(),
name: Some("生存".to_string()),
target: AdoptTargetView::Shared,
}));
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptCommand>(&text).expect("反序列化"),
command
);
}
#[test]
fn scan_summarises_adoptable_and_empty() {
let view = scan_view();
assert_eq!(view.total(), 2);
assert_eq!(view.adoptable(), 1);
assert!(!view.is_empty());
assert!(AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: Vec::new(),
}
.is_empty());
}
#[test]
fn target_slugs_round_trip_and_mark_foreign_dirs() {
for target in AdoptTargetView::ALL {
assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target));
}
assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir);
assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir());
assert!(AdoptTargetView::Shared.touches_foreign_dir());
assert!(AdoptTargetView::VersionDir.touches_foreign_dir());
}
#[test]
fn state_follows_the_four_stages() {
let mut state = AdoptState::default();
assert_eq!(state.stage(), AdoptStage::Pick);
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1));
let plan = AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::InstanceDir,
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()],
};
state.apply(AdoptEvent::Planned(Box::new(plan.clone())));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| &plan.run_dir),
Some(&plan.run_dir)
);
// 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_duplicate_name".to_string(),
message: "已有同名实例".to_string(),
problems: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert!(state.plan.is_none());
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
dir_created: true,
notes: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert!(state.failure.is_none());
// 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选)
state.apply(AdoptEvent::Cleared);
assert_eq!(state.stage(), AdoptStage::Pick);
assert!(state.scan.is_none() && state.done.is_none());
}
+7
View File
@@ -15,6 +15,8 @@
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts;
// M223: 导入已有版本(从别的启动器只读登记 versions/<id>)
mod adopt;
mod agent;
mod content;
mod events;
@@ -38,6 +40,11 @@ pub use accounts::{
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
DeviceLoginView, LoginFailure, SkinImage, REDACTED,
};
pub use adopt::{
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
AdoptStage, AdoptState, AdoptTargetView,
};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,