fix(presenter,app-iced): 「游戏在跑」挡在落 core 之前(M247 r2)

塔裁决:三个写操作在实例启动/运行时必须拒,判据不能只靠界面灰按钮 ——
Windows 上是 os error 32 兜底,**Linux 上删一个正在跑的世界会成功**,那是在
用户玩到一半时把存档删掉。

- host/dispatch.rs:`AppCommand::Saves` 与 `AppCommand::Mods` 同款,先查
  `CoreHost::in_flight(&id)` 再把 `running` 传下去
- host/saves.rs::dispatch 增 `running`:为真时三个写操作**不 spawn、不碰盘**,
  直接回一条 `SavesFailureKind::GameRunning` 的结构化失败;读路径与「打开文件夹」不受影响
- dto:新增 `GameRunning` 类别(不折进 WriteFailed —— 那不是"写失败",是"我们主动没写")
- i18n:「游戏正在运行(先退出再操作)」中英成对 + 金表同步
- 用例:在飞时三条写命令盘上指纹不变/世界还在/备份落点没建,且回 GameRunning;
  反向用例钉住闸门没有把写操作一并关死;UI 侧断言三种动词的主句都不出现「写盘失败」且不画重试
- 拆分(400 行):host/saves.rs → host/saves/write.rs;两处测试各拆出 write 子模块
- SavesState 模块文档补「生命周期」表:draft / pending / confirm 谁写谁清、何时必须清,
  并把 confirm「取走即清」(连点两下不删两个)写成安全不变量
- 出图:ULL_SAVES_FIXTURE=running 一张(失败那句话的样子)
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent c921b9c185
commit 8c67e4eca6
13 files changed
+889 -448

No files matched your search

@@ -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) =
@@ -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;
@@ -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}"
);
}
@@ -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,
})
}
+1
View File
@@ -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`)。
+3
View File
@@ -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)", "游戏正在运行(先退出再操作)"),
} }
};
}
+5
View File
@@ -284,6 +284,11 @@ pub enum SavesFailureKind {
WriteBoundary,
/// 写操作:备份超过体积或条目上限。
WriteTooLarge,
/// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。
///
/// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**,
/// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。
GameRunning,
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
WriteFailed,
}
+10 -3
View File
@@ -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(_)) => {
+86 -143
View File
@@ -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) {
});
}
/// 一条指令属于哪个实例(host 侧算「游戏在不在跑」要用它)。
///
/// 五条命令都带实例 id,所以这里不返回 `Option`:少一条分支处理会让闸门在那条命令上
/// **静默失效**,而这正是 M247 要修的东西。
#[must_use]
pub fn instance_of(command: &SavesCommand) -> &str {
match command {
SavesCommand::List(id)
| SavesCommand::OpenFolder { id }
| SavesCommand::RenameWorld { id, .. }
| SavesCommand::DeleteWorld { id, .. }
| SavesCommand::BackupWorld { id, .. } => id,
}
}
/// 这条命令是不是**改盘**的写操作(读路径与打开文件夹不是)。
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,
}
}
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
///
/// `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 } => 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::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),
}
}
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
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)));
});
}
/// 后台删除一个世界(**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)));
});
}
/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。
pub fn spawn_backup(
env: HostEnv,
tx: JobSender,
id: InstanceId,
dir: String,
dest: Option<String>,
) {
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<SavesListView, SavesFailureView> {
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
fn snapshot(
pub(super) fn snapshot(
instance: &InstanceSummary,
run_dir: &std::path::Path,
last_op: Option<SavesOped>,
@@ -165,74 +198,6 @@ fn snapshot(
Ok(view(&instance.id, &scan, last_op))
}
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
fn rename(
env: &HostEnv,
id: &str,
from: &str,
to: &str,
) -> Result<SavesListView, SavesFailureView> {
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<SavesListView, SavesFailureView> {
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<SavesListView, SavesFailureView> {
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,
}),
)
}
/// 备份的默认落点:`<data>/exports/saves/`(见模块文档「备份落点」一节)。
fn export_dir(env: &HostEnv) -> PathBuf {
env.layout.data().join("exports").join("saves")
}
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
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<PathBuf, String> {
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
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<SavesOped>) -> SavesLi
}
}
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
pub(super) fn failure(
instance: &str,
kind: SavesFailureKind,
detail: impl Into<String>,
) -> 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;
+36 -167
View File
@@ -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);
/// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表:「这一拍有没有写盘」的判据本体。
fn fingerprint(root: &std::path::Path) -> Vec<String> {
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;
};
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(),
for entry in read.flatten() {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) 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));
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;
@@ -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 落到 `<data>/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(),
};
}
+167
View File
@@ -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`] | 备份的默认落点 `<data>/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<String>,
) {
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<SavesListView, SavesFailureView> {
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<SavesListView, SavesFailureView> {
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<SavesListView, SavesFailureView> {
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,
}),
)
}
/// 备份的默认落点:`<data>/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
}
+17
View File
@@ -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`(取走即清、取消后不再有目标)。
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以: