diff --git a/crates/app-iced/src/app/state/saves_shot.rs b/crates/app-iced/src/app/state/saves_shot.rs index 3db00fb..899bb2f 100644 --- a/crates/app-iced/src/app/state/saves_shot.rs +++ b/crates/app-iced/src/app/state/saves_shot.rs @@ -91,6 +91,7 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView { /// | `rename` | 行内重命名进行中(输入框 + 改名/取消) | /// | `confirm` | 删除的二次确认模态框 | /// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) | +/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) | #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { /// 默认:列表 + 行操作区。 @@ -101,6 +102,8 @@ pub enum Mode { Confirm, /// 备份成功的回执。 BackedUp, + /// 写操作被「游戏在运行」挡下。 + Running, } fn mode() -> Mode { @@ -111,6 +114,7 @@ fn mode() -> Mode { "rename" => Mode::Rename, "confirm" => Mode::Confirm, "backed-up" => Mode::BackedUp, + "running" => Mode::Running, _ => Mode::Plain, } } @@ -136,6 +140,17 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) { }; match mode { Mode::Rename => app.state.saves.start_rename(&dir), + // 在飞闸门那条失败(`host::saves::dispatch` 在 `running` 时直接回它)。 + Mode::Running => app + .state + .saves + .failed(unlucky_presenter::dto::SavesFailureView { + instance: instance.to_string(), + file: dir, + op: Some(unlucky_presenter::dto::SavesFailureOp::Rename), + kind: unlucky_presenter::dto::SavesFailureKind::GameRunning, + detail: String::new(), + }), Mode::Confirm => { app.state.saves.ask_delete(&dir); let (title, body, primary, secondary) = diff --git a/crates/app-iced/src/pages/instances/tests/saves.rs b/crates/app-iced/src/pages/instances/tests/saves.rs index 2c29c92..a116479 100644 --- a/crates/app-iced/src/pages/instances/tests/saves.rs +++ b/crates/app-iced/src/pages/instances/tests/saves.rs @@ -8,12 +8,12 @@ //! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。 use unlucky_presenter::dto::{ - SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped, - SavesSkipKind, SavesSkipView, ScreenshotView, WorldView, + SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, + ScreenshotView, WorldView, }; -use unlucky_presenter::state::{SavesOp, SavesState}; +use unlucky_presenter::state::SavesState; -use super::super::saves::{failure_line, from_state, op_line}; +use super::super::saves::{failure_line, from_state}; use super::super::Texts; use unlucky_i18n::Lang; @@ -214,137 +214,6 @@ fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() { assert!(!text.contains('('), "空原文留下了一对括号:{text}"); } -/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。 -/// -/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。 -#[test] -fn write_failures_are_named_per_operation_and_never_retryable() { - let cases = [ - ( - SavesFailureOp::Rename, - SavesFailureKind::WriteConflict, - "重命名 Better World 失败:同名世界已存在", - ), - ( - SavesFailureOp::Delete, - SavesFailureKind::WriteMissing, - "删除 Better World 失败:那个世界已不在", - ), - ( - SavesFailureOp::Backup, - SavesFailureKind::WriteTooLarge, - "备份 Better World 失败:这个世界太大,一个 zip 装不下", - ), - ]; - for (op, kind, expected) in cases { - let failure = SavesFailureView { - instance: "a".to_string(), - file: "Better World".to_string(), - op: Some(op), - kind, - detail: String::new(), - }; - let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog())); - assert_eq!(line, expected); - - let mut state = bound_state("a"); - state.changed(view("a")); - state.failed(failure); - let saves = from_state(&state, Lang::ZhCn.catalog()); - assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮"); - assert!(saves.error.is_some()); - assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了"); - } -} - -/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。 -#[test] -fn draft_pending_and_receipt_become_page_fields() { - let mut state = bound_state("a"); - state.changed(view("a")); - state.start_rename("Better World"); - state.edit_rename("Renamed".to_string()); - let saves = from_state(&state, Lang::En.catalog()); - let editing = saves.editing.as_ref().expect("草稿没进页面模型"); - assert_eq!(editing.dir, "Better World"); - assert_eq!(editing.text, "Renamed"); - assert!(!saves.busy_with("Better World"), "还在打字就算在飞了"); - - state.mark_pending(SavesOp::Backup { - dir: "Better World".to_string(), - }); - let saves = from_state(&state, Lang::En.catalog()); - assert!(saves.editing.is_none(), "发请求时草稿该收起来"); - assert!(saves.busy_with("Better World")); - assert!(!saves.busy_with("Other")); - - // 回执:host 写在快照里,下一次普通 List 就没了。 - let mut done = view("a"); - done.last_op = Some(SavesOped::BackedUp { - dir: "Better World".to_string(), - file: "Better World-20240501-100000.zip".to_string(), - path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(), - bytes: 2_048, - skipped: 2, - }); - state.changed(done); - let saves = from_state(&state, Lang::ZhCn.catalog()); - let notice = saves.notice.as_deref().expect("回执没进页面模型"); - assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}"); - assert!( - notice.contains("Better World-20240501-100000.zip"), - "{notice}" - ); - assert!( - notice.contains("跳过 2 个符号链接"), - "跳过的链接要报出来:{notice}" - ); - assert!(saves.busy.is_none(), "回程之后还在飞"); -} - -/// 三种回执的措辞(重命名 / 删除 / 备份)。 -#[test] -fn every_receipt_says_what_happened() { - let texts = Texts::of(Lang::ZhCn.catalog()); - assert_eq!( - op_line( - &SavesOped::Renamed { - from: "A".to_string(), - to: "B".to_string() - }, - texts - ), - "已把 A 改名为 B。" - ); - assert_eq!( - op_line( - &SavesOped::Deleted { - dir: "A".to_string() - }, - texts - ), - "已删除 A。" - ); - let backed_up = op_line( - &SavesOped::BackedUp { - dir: "A".to_string(), - file: "A.zip".to_string(), - path: "D:/x/A.zip".to_string(), - bytes: 0, - skipped: 0, - }, - texts, - ); - assert!( - backed_up.starts_with("已把 A 备份到 D:/x/A.zip"), - "{backed_up}" - ); - assert!( - !backed_up.contains("符号链接"), - "没跳过就别提链接:{backed_up}" - ); -} - /// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。 #[test] fn millisecond_stamps_are_rendered_in_utc() { @@ -372,3 +241,5 @@ fn millisecond_stamps_are_rendered_in_utc() { stamp(1_709_251_140_000) ); } + +mod write; diff --git a/crates/app-iced/src/pages/instances/tests/saves/write.rs b/crates/app-iced/src/pages/instances/tests/saves/write.rs new file mode 100644 index 0000000..82a9000 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/saves/write.rs @@ -0,0 +1,201 @@ +//! 存档**写操作**的页面模型判据(M247)——从 `tests/saves.rs` 拆出(那边越过 400 行)。 +//! +//! 三条都在 `from_state` 这一层钉:写失败的主句(哪一行 + 哪个动作 + 什么原因)、 +//! 「游戏在运行」不是「写盘失败」(不留重试按钮)、以及草稿 / 在飞 / 回执变成页面字段。 + +use super::super::super::saves::{failure_line, from_state, op_line}; +use super::super::super::Texts; +use super::{bound_state, view}; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView, SavesOped}; +use unlucky_presenter::state::SavesOp; + +/// 写操作失败:**不画重试**(重试按钮会去重新列目录,那是错的动作),并且主句带**哪个世界**。 +/// +/// 抓得住:三种写失败被折叠成同一句「读取 saves/ 失败」——用户看不出是哪一行出了什么事。 +#[test] +fn write_failures_are_named_per_operation_and_never_retryable() { + let cases = [ + ( + SavesFailureOp::Rename, + SavesFailureKind::WriteConflict, + "重命名 Better World 失败:同名世界已存在", + ), + ( + SavesFailureOp::Delete, + SavesFailureKind::WriteMissing, + "删除 Better World 失败:那个世界已不在", + ), + ( + SavesFailureOp::Backup, + SavesFailureKind::WriteTooLarge, + "备份 Better World 失败:这个世界太大,一个 zip 装不下", + ), + ]; + for (op, kind, expected) in cases { + let failure = SavesFailureView { + instance: "a".to_string(), + file: "Better World".to_string(), + op: Some(op), + kind, + detail: String::new(), + }; + let line = failure_line(&failure, Texts::of(Lang::ZhCn.catalog())); + assert_eq!(line, expected); + + let mut state = bound_state("a"); + state.changed(view("a")); + state.failed(failure); + let saves = from_state(&state, Lang::ZhCn.catalog()); + assert!(!saves.retryable, "{op:?} 失败了还画着重试按钮"); + assert!(saves.error.is_some()); + assert_eq!(saves.worlds.len(), 1, "{op:?} 失败把列表清了"); + } +} + +/// 「游戏在跑」不是「写盘失败」:主句要直说先退出,而且**不画重试**。 +/// +/// 抓得住:把这条折进 `WriteFailed` —— 用户会看到「删除 A 失败:写盘失败」,然后以为是权限问题, +/// 反复重试;真正该做的事(退出游戏)一个字都没说。 +#[test] +fn a_running_game_says_exit_first_not_write_failed() { + let cases = [ + ( + SavesFailureOp::Rename, + "重命名 Better World 失败:游戏正在运行(先退出再操作)", + ), + ( + SavesFailureOp::Delete, + "删除 Better World 失败:游戏正在运行(先退出再操作)", + ), + ( + SavesFailureOp::Backup, + "备份 Better World 失败:游戏正在运行(先退出再操作)", + ), + ]; + for (op, expected) in cases { + let failure = SavesFailureView { + instance: "a".to_string(), + file: "Better World".to_string(), + op: Some(op), + kind: SavesFailureKind::GameRunning, + detail: String::new(), + }; + assert_eq!( + failure_line(&failure, Texts::of(Lang::ZhCn.catalog())), + expected + ); + assert!( + !expected.contains("写盘失败"), + "「游戏在跑」被说成了「写盘失败」" + ); + + let mut state = bound_state("a"); + state.changed(view("a")); + state.failed(failure); + let saves = from_state(&state, Lang::ZhCn.catalog()); + assert!(!saves.retryable, "{op:?}:在飞被拒还画重试按钮"); + assert_eq!(saves.worlds.len(), 1, "{op:?}:被拒把列表清了"); + } + // 英文那一侧同一条判据(主语不同、意思相同)。 + let failure = SavesFailureView { + instance: "a".to_string(), + file: "Better World".to_string(), + op: Some(SavesFailureOp::Delete), + kind: SavesFailureKind::GameRunning, + detail: String::new(), + }; + let en = failure_line(&failure, Texts::of(Lang::En.catalog())); + assert_eq!( + en, + "Could not delete Better World: the game is running (exit it first)" + ); +} + +/// 三样交互态(草稿 / 在飞 / 回执)都要变成页面字段:改名中的那一行、置灰的那一行、成功那句话。 +#[test] +fn draft_pending_and_receipt_become_page_fields() { + let mut state = bound_state("a"); + state.changed(view("a")); + state.start_rename("Better World"); + state.edit_rename("Renamed".to_string()); + let saves = from_state(&state, Lang::En.catalog()); + let editing = saves.editing.as_ref().expect("草稿没进页面模型"); + assert_eq!(editing.dir, "Better World"); + assert_eq!(editing.text, "Renamed"); + assert!(!saves.busy_with("Better World"), "还在打字就算在飞了"); + + state.mark_pending(SavesOp::Backup { + dir: "Better World".to_string(), + }); + let saves = from_state(&state, Lang::En.catalog()); + assert!(saves.editing.is_none(), "发请求时草稿该收起来"); + assert!(saves.busy_with("Better World")); + assert!(!saves.busy_with("Other")); + + // 回执:host 写在快照里,下一次普通 List 就没了。 + let mut done = view("a"); + done.last_op = Some(SavesOped::BackedUp { + dir: "Better World".to_string(), + file: "Better World-20240501-100000.zip".to_string(), + path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(), + bytes: 2_048, + skipped: 2, + }); + state.changed(done); + let saves = from_state(&state, Lang::ZhCn.catalog()); + let notice = saves.notice.as_deref().expect("回执没进页面模型"); + assert!(notice.contains("2.0 KB"), "回执要带大小:{notice}"); + assert!( + notice.contains("Better World-20240501-100000.zip"), + "{notice}" + ); + assert!( + notice.contains("跳过 2 个符号链接"), + "跳过的链接要报出来:{notice}" + ); + assert!(saves.busy.is_none(), "回程之后还在飞"); +} + +/// 三种回执的措辞(重命名 / 删除 / 备份)。 +#[test] +fn every_receipt_says_what_happened() { + let texts = Texts::of(Lang::ZhCn.catalog()); + assert_eq!( + op_line( + &SavesOped::Renamed { + from: "A".to_string(), + to: "B".to_string() + }, + texts + ), + "已把 A 改名为 B。" + ); + assert_eq!( + op_line( + &SavesOped::Deleted { + dir: "A".to_string() + }, + texts + ), + "已删除 A。" + ); + let backed_up = op_line( + &SavesOped::BackedUp { + dir: "A".to_string(), + file: "A.zip".to_string(), + path: "D:/x/A.zip".to_string(), + bytes: 0, + skipped: 0, + }, + texts, + ); + assert!( + backed_up.starts_with("已把 A 备份到 D:/x/A.zip"), + "{backed_up}" + ); + assert!( + !backed_up.contains("符号链接"), + "没跳过就别提链接:{backed_up}" + ); +} diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index 69ec36f..39c6cb0 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -288,6 +288,7 @@ impl Texts { SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict, SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary, SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge, + SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning, SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite, }) } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 6d5c08f..d1e2296 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -149,6 +149,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesSavesReasonBoundary", "that path is outside saves/", "那个路径落在 saves/ 之外"), ("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"), ("InstancesSavesReasonWrite", "the write failed", "写盘失败"), + ("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 994b781..9b9ac6e 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -205,6 +205,9 @@ macro_rules! instances_entries { InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"), InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"), InstancesSavesReasonWrite => ("the write failed", "写盘失败"), + // 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的 + // 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。 + InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"), } } }; } diff --git a/crates/presenter/src/dto/saves.rs b/crates/presenter/src/dto/saves.rs index 5afc71f..7f27c6a 100644 --- a/crates/presenter/src/dto/saves.rs +++ b/crates/presenter/src/dto/saves.rs @@ -284,6 +284,11 @@ pub enum SavesFailureKind { WriteBoundary, /// 写操作:备份超过体积或条目上限。 WriteTooLarge, + /// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。 + /// + /// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**, + /// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。 + GameRunning, /// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。 WriteFailed, } diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 4003211..8d06f5d 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -290,10 +290,17 @@ impl CoreHost { let running = self.in_flight(&id); mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); } - // M235:实例存档 / 截图的只读列表 + 打开文件夹 - // (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里) + // M235:实例存档 / 截图的只读列表 + 打开文件夹;M247 加了三个写操作。 + // + // **写操作要看"游戏在不在跑"**(M247 补齐,与 `AppCommand::Mods` 同款判据): + // Windows 上进程占着世界目录 ⇒ rename/删除会撞共享冲突;**Linux 上删一个正在跑的世界 + // 会成功**——那是在用户正在玩的时候把存档删掉。所以判据放在 host 这一侧、落 core 之前, + // 在飞就回一条结构化失败「游戏正在运行,先退出再操作」,一个字节都不碰盘。 + // 读路径与「打开文件夹」不受影响(它们不改盘)。 AppCommand::Saves(command) => { - saves::dispatch(self.env.clone(), self.tx.clone(), *command); + let id = saves::instance_of(&command).to_string(); + let running = self.in_flight(&id); + saves::dispatch(self.env.clone(), self.tx.clone(), *command, running); } // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { diff --git a/crates/presenter/src/host/saves.rs b/crates/presenter/src/host/saves.rs index faa71d5..13e9521 100644 --- a/crates/presenter/src/host/saves.rs +++ b/crates/presenter/src/host/saves.rs @@ -8,6 +8,7 @@ //! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 | //! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) | //! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) | +//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] | //! //! ## 三个写操作的回程形状 //! @@ -20,6 +21,22 @@ //! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。 //! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。 //! +//! ## 「游戏在跑」为什么挡在 host 这一侧(M247) +//! +//! 判据是 `CoreHost::in_flight(&id)`(与 `AppCommand::Mods` 同一个),由 `host::dispatch` 传进来。 +//! 为真时 [`dispatch`] **不 spawn 线程**、直接发一条 [`SavesFailureKind::GameRunning`] 的失败回去: +//! +//! | 平台 | 不挡的后果 | +//! |---|---| +//! | Windows | 进程占着世界目录 ⇒ rename/删除撞共享冲突 ⇒ 用户读一句 `os error 32` | +//! | **Linux** | **删一个正在跑的世界会成功** —— 用户在玩到一半时丢了存档 | +//! +//! 所以这不是"说得更清楚",是**安全性**。也不能只靠界面灰按钮:判据留在 host,UI 那一侧只是 +//! 尽力而为的提示(本版没加"灰按钮"的形态,理由是快照里没有 `running` 字段——加它要动列表 DTO, +//! 而这一版已经有那条明确的失败句子兜住)。 +//! +//! 读路径([`SavesCommand::List`])与「打开文件夹」**不受影响**:它们不改盘。 + //! ## 为什么不复用启动路径的 `run_dir()` //! //! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例 @@ -51,7 +68,7 @@ use std::path::PathBuf; use unlucky_core::game::install::instance::read_instance_config; use unlucky_core::game::isolation::derive_game_dir; -use unlucky_core::game::saves::{self, SavesError, SavesScan, SavesWriteError, SavesWriteKind}; +use unlucky_core::game::saves::{self, SavesError, SavesScan}; use unlucky_core::platform::opener; use crate::dto::{ @@ -63,6 +80,8 @@ use crate::dto::{ use super::env::HostEnv; use super::job::{spawn, JobSender}; +mod write; + /// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Report { @@ -97,54 +116,68 @@ pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) { }); } -/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。 -pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) { +/// 一条指令属于哪个实例(host 侧算「游戏在不在跑」要用它)。 +/// +/// 五条命令都带实例 id,所以这里不返回 `Option`:少一条分支处理会让闸门在那条命令上 +/// **静默失效**,而这正是 M247 要修的东西。 +#[must_use] +pub fn instance_of(command: &SavesCommand) -> &str { match command { - SavesCommand::List(id) => spawn_list(env, tx, id), - SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id), - SavesCommand::RenameWorld { id, dir, to } => spawn_rename(env, tx, id, dir, to), - SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir), - SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest), + SavesCommand::List(id) + | SavesCommand::OpenFolder { id } + | SavesCommand::RenameWorld { id, .. } + | SavesCommand::DeleteWorld { id, .. } + | SavesCommand::BackupWorld { id, .. } => id, } } -/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。 -pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) { - spawn("saves-rename", move || { - let report = Report { - instance: id.clone(), - list: rename(&env, &id, &dir, &to), - }; - let _ = tx.send(super::HostTask::Saves(Box::new(report))); - }); +/// 这条命令是不是**改盘**的写操作(读路径与打开文件夹不是)。 +fn write_target(command: &SavesCommand) -> Option<(&str, SavesFailureOp)> { + match command { + SavesCommand::RenameWorld { dir, .. } => Some((dir, SavesFailureOp::Rename)), + SavesCommand::DeleteWorld { dir, .. } => Some((dir, SavesFailureOp::Delete)), + SavesCommand::BackupWorld { dir, .. } => Some((dir, SavesFailureOp::Backup)), + SavesCommand::List(_) | SavesCommand::OpenFolder { .. } => None, + } } -/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。 -pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) { - spawn("saves-delete", move || { - let report = Report { - instance: id.clone(), - list: delete(&env, &id, &dir), - }; - let _ = tx.send(super::HostTask::Saves(Box::new(report))); - }); +/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。 +/// +/// `running` = 这个实例正在启动/运行中(`CoreHost::in_flight`)。为真时**三个写操作不落 core**: +/// 直接回一条 [`SavesFailureKind::GameRunning`] 的具名失败,一个字节都不碰盘。 +/// +/// 为什么这一层必须挡:Windows 上进程占着世界目录,rename/删除会撞共享冲突(难看但至少失败); +/// **Linux 上删一个正在跑的世界会成功**——那是在用户玩到一半时把存档删掉。所以判据不能只靠 +/// 界面灰按钮(那也是同一条纪律:`ModsCommand::SetEnabled` 同样挡在 host 这一侧)。 +/// +/// 不 spawn 线程:没有磁盘工作要排队,直接发一条结果回去更诚实(`tx` 是同步通道)。 +pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand, running: bool) { + if running { + if let Some((dir, op)) = write_target(&command) { + let id = instance_of(&command); + let report = Report { + instance: id.to_string(), + list: Err(blocked(id, dir, op)), + }; + let _ = tx.send(super::HostTask::Saves(Box::new(report))); + return; + } + } + match command { + SavesCommand::List(id) => spawn_list(env, tx, id), + SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id), + SavesCommand::RenameWorld { id, dir, to } => write::spawn_rename(env, tx, id, dir, to), + SavesCommand::DeleteWorld { id, dir } => write::spawn_delete(env, tx, id, dir), + SavesCommand::BackupWorld { id, dir, dest } => write::spawn_backup(env, tx, id, dir, dest), + } } -/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。 -pub fn spawn_backup( - env: HostEnv, - tx: JobSender, - id: InstanceId, - dir: String, - dest: Option, -) { - spawn("saves-backup", move || { - let report = Report { - instance: id.clone(), - list: backup(&env, &id, &dir, dest.as_deref()), - }; - let _ = tx.send(super::HostTask::Saves(Box::new(report))); - }); +/// 写操作被「游戏在跑」挡下时的那条失败(**结构化,带世界名与操作**,文案由 app 层现求)。 +fn blocked(instance: &str, dir: &str, op: SavesFailureOp) -> SavesFailureView { + let mut failure = failure(instance, SavesFailureKind::GameRunning, String::new()); + failure.file = dir.to_string(); + failure.op = Some(op); + failure } /// 列目录(后台线程里调)。 @@ -156,7 +189,7 @@ fn list(env: &HostEnv, id: &str) -> Result { /// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。 /// /// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。 -fn snapshot( +pub(super) fn snapshot( instance: &InstanceSummary, run_dir: &std::path::Path, last_op: Option, @@ -165,74 +198,6 @@ fn snapshot( Ok(view(&instance.id, &scan, last_op)) } -/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。 -fn rename( - env: &HostEnv, - id: &str, - from: &str, - to: &str, -) -> Result { - let (instance, run_dir) = target(env, id)?; - saves::rename_world(&run_dir, from, to) - .map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?; - snapshot( - &instance, - &run_dir, - Some(SavesOped::Renamed { - from: from.to_string(), - to: to.to_string(), - }), - ) -} - -/// 删除 → 再列一遍(回执里记被删掉的那个名字)。 -fn delete(env: &HostEnv, id: &str, dir: &str) -> Result { - let (instance, run_dir) = target(env, id)?; - saves::delete_world(&run_dir, dir) - .map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?; - snapshot( - &instance, - &run_dir, - Some(SavesOped::Deleted { - dir: dir.to_string(), - }), - ) -} - -/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。 -fn backup( - env: &HostEnv, - id: &str, - dir: &str, - dest: Option<&str>, -) -> Result { - let (instance, run_dir) = target(env, id)?; - let dest = dest.map_or_else(|| export_dir(env), PathBuf::from); - let done = saves::backup_world(&run_dir, dir, &dest) - .map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?; - let file = done - .path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(); - snapshot( - &instance, - &run_dir, - Some(SavesOped::BackedUp { - dir: dir.to_string(), - file, - path: done.path.display().to_string(), - bytes: done.bytes, - skipped: done.skipped, - }), - ) -} - -/// 备份的默认落点:`/exports/saves/`(见模块文档「备份落点」一节)。 -fn export_dir(env: &HostEnv) -> PathBuf { - env.layout.data().join("exports").join("saves") -} - /// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。 fn open_folder(env: &HostEnv, id: &str) -> Result { let (instance, run_dir) = target(env, id)?; @@ -255,7 +220,10 @@ fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf { } /// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。 -fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFailureView> { +pub(super) fn target( + env: &HostEnv, + id: &str, +) -> Result<(InstanceSummary, PathBuf), SavesFailureView> { let instances = super::game::instances(env); let Some(instance) = locate(&instances, id) else { return Err(failure( @@ -270,7 +238,7 @@ fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFa } /// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 -fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { +pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { let global = super::game::global_mode(env); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; @@ -334,7 +302,11 @@ fn view(instance: &str, scan: &SavesScan, last_op: Option) -> SavesLi } } -fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into) -> SavesFailureView { +pub(super) fn failure( + instance: &str, + kind: SavesFailureKind, + detail: impl Into, +) -> SavesFailureView { SavesFailureView { instance: instance.to_string(), file: String::new(), @@ -353,34 +325,5 @@ fn unusable(instance: &str, error: &SavesError) -> SavesFailureView { ) } -/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。 -/// -/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。 -fn write_failure( - instance: &str, - op: SavesFailureOp, - file: &str, - error: &SavesWriteError, -) -> SavesFailureView { - let kind = match error.kind { - SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName, - SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink, - SavesWriteKind::NotFound => SavesFailureKind::WriteMissing, - SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory, - SavesWriteKind::Conflict => SavesFailureKind::WriteConflict, - SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary, - SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge, - SavesWriteKind::Io => SavesFailureKind::WriteFailed, - }; - let mut failure = failure( - instance, - kind, - format!("{}:{}", error.path.display(), error.detail), - ); - failure.file = file.to_string(); - failure.op = Some(op); - failure -} - #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/saves/tests.rs b/crates/presenter/src/host/saves/tests.rs index 5a0307d..3d4c1a8 100644 --- a/crates/presenter/src/host/saves/tests.rs +++ b/crates/presenter/src/host/saves/tests.rs @@ -6,6 +6,10 @@ //! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断 //! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住, //! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。 +//! +//! 「游戏在跑就拒」(M247)由 [`write_commands_do_not_touch_the_disk_while_the_game_is_running`] +//! 从 **`dispatch` 那一层**验:三条写命令一条都不落 core(盘上指纹前后一致 + 备份落点没被建), +//! 并且回一条 `GameRunning` 的具名失败。 use super::*; use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind}; @@ -204,175 +208,40 @@ fn every_core_skip_reason_keeps_its_own_kind() { } } -/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。 -/// -/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。 -#[test] -fn rename_round_trips_on_a_real_root() { - let (_root, env, run) = fixture("saves-rename"); - world(&run, "Old World"); - world(&run, "Other"); - - let listed = rename(&env, ID, "Old World", "New World").expect("改名"); - assert!(!run.join("saves/Old World").exists(), "旧目录还在"); - assert!( - run.join("saves/New World/level.dat").exists(), - "新目录里没有内容" - ); - let names: Vec<&str> = listed - .worlds - .iter() - .map(|world| world.dir.as_str()) - .collect(); - assert_eq!(names, ["New World", "Other"], "快照没跟着刷新"); - assert_eq!( - listed.last_op, - Some(SavesOped::Renamed { - from: "Old World".to_string(), - to: "New World".to_string() - }) - ); -} - -/// **端到端**:删除 → 真盘没了 + 新快照 + 回执。 -#[test] -fn delete_round_trips_on_a_real_root() { - let (_root, env, run) = fixture("saves-delete"); - world(&run, "Doomed"); - world(&run, "Kept"); - - let listed = delete(&env, ID, "Doomed").expect("删除"); - assert!(!run.join("saves/Doomed").exists(), "目录还在"); - let names: Vec<&str> = listed - .worlds - .iter() - .map(|world| world.dir.as_str()) - .collect(); - assert_eq!(names, ["Kept"], "别的世界被连带删了 / 快照没刷新"); - assert_eq!( - listed.last_op, - Some(SavesOped::Deleted { - dir: "Doomed".to_string() - }) - ); -} - -/// **端到端**:备份 → zip 落在启动器自己的 `exports/saves/`,回执带路径与字节数。 -#[test] -fn backup_round_trips_to_the_exports_dir() { - let (_root, env, run) = fixture("saves-backup"); - world(&run, "My World"); - - let listed = backup(&env, ID, "My World", None).expect("备份"); - let exports = env.layout.data().join("exports").join("saves"); - let Some(SavesOped::BackedUp { - dir, - file, - path, - bytes, - skipped, - }) = listed.last_op.clone() - else { - panic!("备份成功却没有回执:{:?}", listed.last_op); - }; - assert_eq!(dir, "My World"); - assert!( - file.starts_with("My World-"), - "文件名要带世界名与时戳:{file}" - ); - assert!(file.ends_with(".zip"), "文件名要带 .zip:{file}"); - assert_eq!(std::path::PathBuf::from(&path), exports.join(&file)); - assert!(std::path::Path::new(&path).exists(), "zip 没真的落盘"); - assert!(bytes > 0); - assert_eq!(skipped, 0); - // 备份不改世界本身:列表还是一行。 - assert_eq!(listed.world_count(), 1); - // 显式落点也要照用(将来接了目录选择框就走这条)。 - let custom = backup( - &env, - ID, - "My World", - Some(&run.join("elsewhere").display().to_string()), - ) - .expect("备份到指定目录"); - assert!(matches!(custom.last_op, Some(SavesOped::BackedUp { .. }))); - assert!(run.join("elsewhere").is_dir(), "显式落点没被采用"); -} - -/// 八种写失败各给**自己的**类别(没有一个落到「未知」兜底)。 -#[test] -fn every_write_failure_keeps_its_own_kind() { - let (_root, env, run) = fixture("saves-write-kinds"); - world(&run, "Taken"); - world(&run, "Other"); - - let cases = [ - ("Other", "Taken", SavesFailureKind::WriteConflict), - ("Other", "..", SavesFailureKind::WriteUnsafeName), - ("Nope", "Fresh", SavesFailureKind::WriteMissing), - ]; - for (from, to, expected) in cases { - let failure = rename(&env, ID, from, to).expect_err("改成功了"); - assert_eq!(failure.kind, expected, "{from} → {to}:{}", failure.detail); - } - - let failure = delete(&env, ID, "Nope").expect_err("删成功了"); - assert_eq!(failure.kind, SavesFailureKind::WriteMissing); - // 空 / 相对的落点目录:会落到进程当前目录,所以是"越界"而不是"IO 失败"。 - let failure = backup(&env, ID, "Taken", Some("")).expect_err("备份到空目录居然成功了"); - assert_eq!( - failure.kind, - SavesFailureKind::WriteBoundary, - "{}", - failure.detail - ); - - // core 的八个类别 → DTO 八个类别(一一对应,不折叠)。 - let pairs = [ - ( - SavesWriteKind::UnsafeName, - SavesFailureKind::WriteUnsafeName, - ), - (SavesWriteKind::SymbolicLink, SavesFailureKind::WriteSymlink), - (SavesWriteKind::NotFound, SavesFailureKind::WriteMissing), - ( - SavesWriteKind::NotADirectory, - SavesFailureKind::WriteNotADirectory, - ), - (SavesWriteKind::Conflict, SavesFailureKind::WriteConflict), - (SavesWriteKind::Boundary, SavesFailureKind::WriteBoundary), - (SavesWriteKind::TooLarge, SavesFailureKind::WriteTooLarge), - (SavesWriteKind::Io, SavesFailureKind::WriteFailed), - ]; - for (core, expected) in pairs { - let error = SavesWriteError { - path: PathBuf::from("C:/mc/saves/x"), - kind: core, - detail: "原文".to_string(), +/// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表:「这一拍有没有写盘」的判据本体。 +fn fingerprint(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(read) = std::fs::read_dir(&dir) else { + continue; }; - let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error); - assert_eq!(failure.kind, expected, "{core:?}"); - assert!( - failure.detail.contains("saves"), - "原文丢了:{}", - failure.detail - ); - assert_eq!(failure.instance, ID); - assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行"); - assert_eq!(failure.op, Some(SavesFailureOp::Backup)); + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { + continue; + }; + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string(); + out.push(format!( + "{rel}|dir={}|len={}|mtime={}", + meta.is_dir(), + meta.len(), + meta.modified() + .ok() + .and_then(|time| time.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map_or(0, |since| since.as_millis()) + )); + if meta.is_dir() { + stack.push(path); + } + } } + out.sort(); + out } -/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。 -#[test] -fn write_ops_on_a_vanished_instance_are_named() { - let (_root, env, _run) = fixture("saves-write-unknown"); - for failure in [ - rename(&env, "gone", "A", "B").expect_err("改名成功了"), - delete(&env, "gone", "A").expect_err("删除成功了"), - backup(&env, "gone", "A", None).expect_err("备份成功了"), - ] { - assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable); - assert_eq!(failure.instance, "gone"); - } -} +mod write; diff --git a/crates/presenter/src/host/saves/tests/write.rs b/crates/presenter/src/host/saves/tests/write.rs new file mode 100644 index 0000000..a45621e --- /dev/null +++ b/crates/presenter/src/host/saves/tests/write.rs @@ -0,0 +1,341 @@ +//! 三个**写操作**与「游戏在跑」闸门的用例(M247)——从 `host/saves/tests.rs` 拆出 +//! (那边加完 7 条之后越过 400 行硬线;判据一条不改)。 +//! +//! | 用例 | 抓得住 | +//! |---|---| +//! | [`rename_round_trips_on_a_real_root`] / [`delete_round_trips_on_a_real_root`] | 真盘改动 + 快照刷新 + 回执对得上 | +//! | [`backup_round_trips_to_the_exports_dir`] | zip 落到 `/exports/saves/`,回执带路径/字节/跳过数;显式落点也照用 | +//! | [`every_write_failure_keeps_its_own_kind`] | core 八类 → DTO 八类一一对应(不折叠),带世界名与操作 | +//! | [`write_commands_do_not_touch_the_disk_while_the_game_is_running`] | **在飞时一条都不落 core**:盘上指纹不变、世界还在、备份落点没建、回 `GameRunning` | +//! | [`write_commands_run_when_the_game_is_not_running`] | 闸门没有把写操作一并关死 | +//! | [`read_commands_are_not_blocked_while_the_game_is_running`] | 读路径与打开文件夹不受影响 | +//! | [`every_command_names_its_instance`] | 五条命令都答得上「哪个实例」(闸门靠它算在不在跑) | + +use super::super::write::{backup, delete, rename, write_failure}; +use super::super::*; +use super::{fingerprint, fixture, world, ID}; +use crate::dto::{SavesCommand, SavesFailureOp, SavesFailureView, SavesOped}; +use unlucky_core::game::saves::SavesWriteKind; + +/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。 +/// +/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。 +#[test] +fn rename_round_trips_on_a_real_root() { + let (_root, env, run) = fixture("saves-rename"); + world(&run, "Old World"); + world(&run, "Other"); + + let listed = rename(&env, ID, "Old World", "New World").expect("改名"); + assert!(!run.join("saves/Old World").exists(), "旧目录还在"); + assert!( + run.join("saves/New World/level.dat").exists(), + "新目录里没有内容" + ); + let names: Vec<&str> = listed + .worlds + .iter() + .map(|world| world.dir.as_str()) + .collect(); + assert_eq!(names, ["New World", "Other"], "快照没跟着刷新"); + assert_eq!( + listed.last_op, + Some(SavesOped::Renamed { + from: "Old World".to_string(), + to: "New World".to_string() + }) + ); +} + +/// **端到端**:删除 → 真盘没了 + 新快照 + 回执。 +#[test] +fn delete_round_trips_on_a_real_root() { + let (_root, env, run) = fixture("saves-delete"); + world(&run, "Doomed"); + world(&run, "Kept"); + + let listed = delete(&env, ID, "Doomed").expect("删除"); + assert!(!run.join("saves/Doomed").exists(), "目录还在"); + let names: Vec<&str> = listed + .worlds + .iter() + .map(|world| world.dir.as_str()) + .collect(); + assert_eq!(names, ["Kept"], "别的世界被连带删了 / 快照没刷新"); + assert_eq!( + listed.last_op, + Some(SavesOped::Deleted { + dir: "Doomed".to_string() + }) + ); +} + +/// **端到端**:备份 → zip 落在启动器自己的 `exports/saves/`,回执带路径与字节数。 +#[test] +fn backup_round_trips_to_the_exports_dir() { + let (_root, env, run) = fixture("saves-backup"); + world(&run, "My World"); + + let listed = backup(&env, ID, "My World", None).expect("备份"); + let exports = env.layout.data().join("exports").join("saves"); + let Some(SavesOped::BackedUp { + dir, + file, + path, + bytes, + skipped, + }) = listed.last_op.clone() + else { + panic!("备份成功却没有回执:{:?}", listed.last_op); + }; + assert_eq!(dir, "My World"); + assert!( + file.starts_with("My World-"), + "文件名要带世界名与时戳:{file}" + ); + assert!(file.ends_with(".zip"), "文件名要带 .zip:{file}"); + assert_eq!(std::path::PathBuf::from(&path), exports.join(&file)); + assert!(std::path::Path::new(&path).exists(), "zip 没真的落盘"); + assert!(bytes > 0); + assert_eq!(skipped, 0); + // 备份不改世界本身:列表还是一行。 + assert_eq!(listed.world_count(), 1); + // 显式落点也要照用(将来接了目录选择框就走这条)。 + let custom = backup( + &env, + ID, + "My World", + Some(&run.join("elsewhere").display().to_string()), + ) + .expect("备份到指定目录"); + assert!(matches!(custom.last_op, Some(SavesOped::BackedUp { .. }))); + assert!(run.join("elsewhere").is_dir(), "显式落点没被采用"); +} + +/// 八种写失败各给**自己的**类别(没有一个落到「未知」兜底)。 +#[test] +fn every_write_failure_keeps_its_own_kind() { + let (_root, env, run) = fixture("saves-write-kinds"); + world(&run, "Taken"); + world(&run, "Other"); + + let cases = [ + ("Other", "Taken", SavesFailureKind::WriteConflict), + ("Other", "..", SavesFailureKind::WriteUnsafeName), + ("Nope", "Fresh", SavesFailureKind::WriteMissing), + ]; + for (from, to, expected) in cases { + let failure = rename(&env, ID, from, to).expect_err("改成功了"); + assert_eq!(failure.kind, expected, "{from} → {to}:{}", failure.detail); + } + + let failure = delete(&env, ID, "Nope").expect_err("删成功了"); + assert_eq!(failure.kind, SavesFailureKind::WriteMissing); + // 空 / 相对的落点目录:会落到进程当前目录,所以是「越界」而不是「IO 失败」。 + let failure = backup(&env, ID, "Taken", Some("")).expect_err("备份到空目录居然成功了"); + assert_eq!( + failure.kind, + SavesFailureKind::WriteBoundary, + "{}", + failure.detail + ); + + // core 的八个类别 → DTO 八个类别(一一对应,不折叠)。 + let pairs = [ + ( + SavesWriteKind::UnsafeName, + SavesFailureKind::WriteUnsafeName, + ), + (SavesWriteKind::SymbolicLink, SavesFailureKind::WriteSymlink), + (SavesWriteKind::NotFound, SavesFailureKind::WriteMissing), + ( + SavesWriteKind::NotADirectory, + SavesFailureKind::WriteNotADirectory, + ), + (SavesWriteKind::Conflict, SavesFailureKind::WriteConflict), + (SavesWriteKind::Boundary, SavesFailureKind::WriteBoundary), + (SavesWriteKind::TooLarge, SavesFailureKind::WriteTooLarge), + (SavesWriteKind::Io, SavesFailureKind::WriteFailed), + ]; + for (core, expected) in pairs { + let error = unlucky_core::game::saves::SavesWriteError { + path: PathBuf::from("C:/mc/saves/x"), + kind: core, + detail: "原文".to_string(), + }; + let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error); + assert_eq!(failure.kind, expected, "{core:?}"); + assert!( + failure.detail.contains("saves"), + "原文丢了:{}", + failure.detail + ); + assert_eq!(failure.instance, ID); + assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行"); + assert_eq!(failure.op, Some(SavesFailureOp::Backup)); + } +} + +/// **在飞闸门**:三条写命令一条都不落 core —— 盘上指纹前后一致、世界还在、备份落点没被建, +/// 而且回的是 `GameRunning`(不是"写盘失败")。 +/// +/// 抓得住:把闸门放回 UI(只灰按钮)——那时 `dispatch(..., running = true)` 会真的删掉世界, +/// 这条用例立刻红(指纹变了 / 世界不在 / `kind` 不是 `GameRunning`)。 +#[test] +fn write_commands_do_not_touch_the_disk_while_the_game_is_running() { + let (_root, env, run) = fixture("saves-running"); + world(&run, "Doomed"); + let before = fingerprint(&run); + let (tx, rx) = super::super::super::job::channel(); + + let commands = [ + SavesCommand::RenameWorld { + id: ID.to_string(), + dir: "Doomed".to_string(), + to: "Renamed".to_string(), + }, + SavesCommand::DeleteWorld { + id: ID.to_string(), + dir: "Doomed".to_string(), + }, + SavesCommand::BackupWorld { + id: ID.to_string(), + dir: "Doomed".to_string(), + dest: None, + }, + ]; + for command in commands { + let expected = match &command { + SavesCommand::RenameWorld { .. } => SavesFailureOp::Rename, + SavesCommand::DeleteWorld { .. } => SavesFailureOp::Delete, + _ => SavesFailureOp::Backup, + }; + dispatch(env.clone(), tx.clone(), command, true); + let task = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("在飞时 host 没有回结果"); + let crate::host::HostTask::Saves(report) = task else { + panic!("回程不是存档事件"); + }; + assert_eq!(report.instance, ID); + let failure = report.list.expect_err("在飞时居然写成功了"); + assert_eq!( + failure.kind, + SavesFailureKind::GameRunning, + "{}", + failure.detail + ); + assert_eq!(failure.file, "Doomed", "失败那句话要指名哪一行"); + assert_eq!(failure.op, Some(expected)); + } + + assert_eq!(fingerprint(&run), before, "在飞时写了盘"); + assert!( + run.join("saves/Doomed/level.dat").exists(), + "世界被删/被改名了" + ); + assert!( + !env.layout.data().join("exports").exists(), + "在飞时备份落了盘" + ); +} + +/// 反向:不在飞时同一条 `dispatch` 该真的干(闸门不是把写操作一并关死)。 +#[test] +fn write_commands_run_when_the_game_is_not_running() { + let (_root, env, run) = fixture("saves-idle-dispatch"); + world(&run, "Doomed"); + let (tx, rx) = super::super::super::job::channel(); + + dispatch( + env.clone(), + tx.clone(), + SavesCommand::DeleteWorld { + id: ID.to_string(), + dir: "Doomed".to_string(), + }, + false, + ); + let task = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("host 没有回结果"); + let crate::host::HostTask::Saves(report) = task else { + panic!("回程不是存档事件"); + }; + let listed = report.list.expect("不在飞时该照常删"); + assert!(!run.join("saves/Doomed").exists(), "闸门把写操作一并关死了"); + assert!(matches!(listed.last_op, Some(SavesOped::Deleted { .. }))); +} + +/// 读路径**不受**在飞闸门影响(它不改盘)。 +#[test] +fn read_commands_are_not_blocked_while_the_game_is_running() { + let (_root, env, run) = fixture("saves-running-read"); + world(&run, "Kept"); + let (tx, rx) = super::super::super::job::channel(); + + dispatch( + env.clone(), + tx.clone(), + SavesCommand::List(ID.to_string()), + true, + ); + let task = rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("host 没有回结果"); + let crate::host::HostTask::Saves(report) = task else { + panic!("回程不是存档事件"); + }; + let listed = report.list.expect("在飞时读列表不该被拒"); + assert_eq!(listed.world_count(), 1); + assert!(listed.last_op.is_none()); +} + +/// 每一层都认得同一个实例 id(闸门用它算「在不在跑」——漏一条分支就等于那条命令没有闸门)。 +#[test] +fn every_command_names_its_instance() { + let commands = [ + SavesCommand::List(ID.to_string()), + SavesCommand::OpenFolder { id: ID.to_string() }, + SavesCommand::RenameWorld { + id: ID.to_string(), + dir: "A".to_string(), + to: "B".to_string(), + }, + SavesCommand::DeleteWorld { + id: ID.to_string(), + dir: "A".to_string(), + }, + SavesCommand::BackupWorld { + id: ID.to_string(), + dir: "A".to_string(), + dest: None, + }, + ]; + assert_eq!(commands.len(), 5, "新增了命令就要在这里补一条"); + for command in &commands { + assert_eq!(instance_of(command), ID, "{command:?}"); + } +} + +/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。 +#[test] +fn write_ops_on_a_vanished_instance_are_named() { + let (_root, env, _run) = fixture("saves-write-unknown"); + for failure in [ + rename(&env, "gone", "A", "B").expect_err("改名成功了"), + delete(&env, "gone", "A").expect_err("删除成功了"), + backup(&env, "gone", "A", None).expect_err("备份成功了"), + ] { + assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable); + assert_eq!(failure.instance, "gone"); + } + // 这个用例只借 `SavesFailureView` 的类型做一次构造检查:字段齐了才算「结构化失败」。 + let _probe = SavesFailureView { + instance: "gone".to_string(), + file: String::new(), + op: None, + kind: SavesFailureKind::InstanceUnavailable, + detail: String::new(), + }; +} diff --git a/crates/presenter/src/host/saves/write.rs b/crates/presenter/src/host/saves/write.rs new file mode 100644 index 0000000..fac574d --- /dev/null +++ b/crates/presenter/src/host/saves/write.rs @@ -0,0 +1,167 @@ +//! 三个**写操作**的宿主实现(M247):重命名 / 删除 / 备份导出。 +//! +//! 从 `host/saves.rs` 拆出(那个文件加完在飞闸门与写失败映射之后越过 400 行硬线)。 +//! 判据与读路径共用的部分留在父模块([`super::target`] / [`super::run_dir`] / [`super::snapshot`] / +//! [`super::failure`])——**派生运行目录只有一条路**,写路径不另算一份。 +//! +//! | 函数 | 干什么 | +//! |---|---| +//! | [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] | 起后台线程跑完再把**新快照 + 回执**送回去 | +//! | `rename` / `delete` / `backup` | 前台实现(测试直接调它们,不必绕线程) | +//! | [`export_dir`] | 备份的默认落点 `/exports/saves/` | +//! | [`write_failure`] | core 的八类写失败 → DTO 的八类(一一对应,不折叠) | +//! +//! 三个 `spawn_*` 都**先跑 core 的写**再 `snapshot`:界面画的永远是磁盘上的真相, +//! 回执只说这件事动的是哪一条(见父模块的「三个写操作的回程形状」)。 + +use std::path::PathBuf; + +use unlucky_core::game::saves::{self, SavesWriteError, SavesWriteKind}; + +use crate::dto::{ + InstanceId, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped, +}; + +use super::super::env::HostEnv; +use super::super::job::{spawn, JobSender}; +use super::{failure, snapshot, target, Report}; + +/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。 +pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) { + spawn("saves-rename", move || { + let report = Report { + instance: id.clone(), + list: rename(&env, &id, &dir, &to), + }; + let _ = tx.send(crate::host::HostTask::Saves(Box::new(report))); + }); +} + +/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。 +pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) { + spawn("saves-delete", move || { + let report = Report { + instance: id.clone(), + list: delete(&env, &id, &dir), + }; + let _ = tx.send(crate::host::HostTask::Saves(Box::new(report))); + }); +} + +/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。 +pub fn spawn_backup( + env: HostEnv, + tx: JobSender, + id: InstanceId, + dir: String, + dest: Option, +) { + spawn("saves-backup", move || { + let report = Report { + instance: id.clone(), + list: backup(&env, &id, &dir, dest.as_deref()), + }; + let _ = tx.send(crate::host::HostTask::Saves(Box::new(report))); + }); +} + +/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。 +pub(super) fn rename( + env: &HostEnv, + id: &str, + from: &str, + to: &str, +) -> Result { + let (instance, run_dir) = target(env, id)?; + saves::rename_world(&run_dir, from, to) + .map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?; + snapshot( + &instance, + &run_dir, + Some(SavesOped::Renamed { + from: from.to_string(), + to: to.to_string(), + }), + ) +} + +/// 删除 → 再列一遍(回执里记被删掉的那个名字)。 +pub(super) fn delete( + env: &HostEnv, + id: &str, + dir: &str, +) -> Result { + let (instance, run_dir) = target(env, id)?; + saves::delete_world(&run_dir, dir) + .map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?; + snapshot( + &instance, + &run_dir, + Some(SavesOped::Deleted { + dir: dir.to_string(), + }), + ) +} + +/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。 +pub(super) fn backup( + env: &HostEnv, + id: &str, + dir: &str, + dest: Option<&str>, +) -> Result { + let (instance, run_dir) = target(env, id)?; + let dest = dest.map_or_else(|| export_dir(env), PathBuf::from); + let done = saves::backup_world(&run_dir, dir, &dest) + .map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?; + let file = done + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + snapshot( + &instance, + &run_dir, + Some(SavesOped::BackedUp { + dir: dir.to_string(), + file, + path: done.path.display().to_string(), + bytes: done.bytes, + skipped: done.skipped, + }), + ) +} + +/// 备份的默认落点:`/exports/saves/`(见模块文档「备份落点」一节)。 +fn export_dir(env: &HostEnv) -> PathBuf { + env.layout.data().join("exports").join("saves") +} + +/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。 +/// +/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。 +pub(super) fn write_failure( + instance: &str, + op: SavesFailureOp, + file: &str, + error: &SavesWriteError, +) -> SavesFailureView { + let kind = match error.kind { + SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName, + SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink, + SavesWriteKind::NotFound => SavesFailureKind::WriteMissing, + SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory, + SavesWriteKind::Conflict => SavesFailureKind::WriteConflict, + SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary, + SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge, + SavesWriteKind::Io => SavesFailureKind::WriteFailed, + }; + let mut failure = failure( + instance, + kind, + format!("{}:{}", error.path.display(), error.detail), + ); + failure.file = file.to_string(); + failure.op = Some(op); + failure +} diff --git a/crates/presenter/src/state/saves.rs b/crates/presenter/src/state/saves.rs index cd99dea..714decb 100644 --- a/crates/presenter/src/state/saves.rs +++ b/crates/presenter/src/state/saves.rs @@ -11,6 +11,23 @@ //! | [`SavesState::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) | //! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 | //! +//! ## 生命周期(谁写、谁清、何时必须清) +//! +//! | 字段 | 谁写 | 谁清 | 必须清的时刻 | +//! |---|---|---|---| +//! | `draft` | `start_rename`(预填当前磁盘名)、`edit_rename`(每敲一个字) | `cancel_rename` / `take_rename`(提交时取走)/ `mark_pending`(请求已发出,草稿没用了)/ `bind` / `changed` | **换实例**、**请求发出**、**快照回来**:三者之后草稿都属于"上一次对话",留着会让「改名」按钮改到一个已经在别的实例上的名字 | +//! | `pending` | `mark_pending`(发请求之前,一跳都不等) | `changed`(成功)/ `failed`(失败也要清——**按钮不能永远灰着**,用户要能直接再点一次)/ `bind` | **任何一次后台往返收尾**、**换实例** | +//! | `confirm` | `ask_delete`(点「删除」时,随后 app 层把模态框压进队列) | [`SavesState::take_confirm`](**取走即清**)/ `clear_confirm`(取消或关框)/ `failed` / `bind` | **点主按钮那一刻**、**取消/关闭模态框**、**换实例** | +//! +//! ### `confirm` 的「取走即清」是一条**安全不变量** +//! +//! 主按钮走的是 `take_confirm()`:拿到目标的同时字段被置空。所以同一次确认**连点两下只会发一条 +//! 删除**——第二下拿不到目标,只关框。这不是洁癖:删除不可逆,一次误触发两条命令意味着第二条 +//! 会去删一个已经被删掉的名字(或者更糟:用户在两次点击之间切了实例,而旧目标还留着)。 +//! 取消/关闭也必须清,否则"上一次取消掉的删除"会挂在状态里,等下一次点「确定」时兑现。 +//! 用例:`switching_instances_drops_draft_pending_and_confirm`(换实例三样一起清)、 +//! `the_delete_confirmation_hands_out_once`(取走即清、取消后不再有目标)。 +//! //! # 为什么快照住在这里而不是页面 //! //! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以: