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` | 行内重命名进行中(输入框 + 改名/取消) | /// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 | /// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) | /// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
/// | `running` | 「游戏正在运行,先退出再操作」那条写失败(M247 的在飞闸门) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { pub enum Mode {
/// 默认:列表 + 行操作区。 /// 默认:列表 + 行操作区。
@@ -101,6 +102,8 @@ pub enum Mode {
Confirm, Confirm,
/// 备份成功的回执。 /// 备份成功的回执。
BackedUp, BackedUp,
/// 写操作被「游戏在运行」挡下。
Running,
} }
fn mode() -> Mode { fn mode() -> Mode {
@@ -111,6 +114,7 @@ fn mode() -> Mode {
"rename" => Mode::Rename, "rename" => Mode::Rename,
"confirm" => Mode::Confirm, "confirm" => Mode::Confirm,
"backed-up" => Mode::BackedUp, "backed-up" => Mode::BackedUp,
"running" => Mode::Running,
_ => Mode::Plain, _ => Mode::Plain,
} }
} }
@@ -136,6 +140,17 @@ pub fn seed(app: &mut crate::app::state::App, instance: &str) {
}; };
match mode { match mode {
Mode::Rename => app.state.saves.start_rename(&dir), 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 => { Mode::Confirm => {
app.state.saves.ask_delete(&dir); app.state.saves.ask_delete(&dir);
let (title, body, primary, secondary) = let (title, body, primary, secondary) =
@@ -8,12 +8,12 @@
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。 //! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
use unlucky_presenter::dto::{ use unlucky_presenter::dto::{
SavesArea, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped, SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
SavesSkipKind, SavesSkipView, ScreenshotView, WorldView, 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 super::super::Texts;
use unlucky_i18n::Lang; 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}"); 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`(跨年/闰日各钉一条,别只信一条)。 /// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
#[test] #[test]
fn millisecond_stamps_are_rendered_in_utc() { fn millisecond_stamps_are_rendered_in_utc() {
@@ -372,3 +241,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
stamp(1_709_251_140_000) 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::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary, SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge, SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::GameRunning => Key::InstancesSavesReasonRunning,
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite, 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/ 之外"), ("InstancesSavesReasonBoundary", "that path is outside saves/", "那个路径落在 saves/ 之外"),
("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"), ("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
("InstancesSavesReasonWrite", "the write failed", "写盘失败"), ("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
]; ];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+3
View File
@@ -205,6 +205,9 @@ macro_rules! instances_entries {
InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"), InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"),
InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"), InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
InstancesSavesReasonWrite => ("the write failed", "写盘失败"), InstancesSavesReasonWrite => ("the write failed", "写盘失败"),
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
} } } }
}; };
} }
+5
View File
@@ -284,6 +284,11 @@ pub enum SavesFailureKind {
WriteBoundary, WriteBoundary,
/// 写操作:备份超过体积或条目上限。 /// 写操作:备份超过体积或条目上限。
WriteTooLarge, WriteTooLarge,
/// 写操作:实例正在启动/运行中,**一个字节都没碰盘**(见 `host::saves::dispatch`)。
///
/// 单独一类而不是折进 [`WriteFailed`](Self::WriteFailed):Linux 上删一个正在跑的世界**会成功**,
/// 所以这不是"写盘失败了",是"我们主动没写"。界面必须说「游戏正在运行,先退出再操作」。
GameRunning,
/// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。 /// 写操作:其它系统错误(权限 / 被别的进程占用),OS 原文进 `detail`。
WriteFailed, WriteFailed,
} }
+10 -3
View File
@@ -290,10 +290,17 @@ impl CoreHost {
let running = self.in_flight(&id); let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
} }
// M235:实例存档 / 截图的只读列表 + 打开文件夹 // M235:实例存档 / 截图的只读列表 + 打开文件夹;M247 加了三个写操作。
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里) //
// **写操作要看"游戏在不在跑"**(M247 补齐,与 `AppCommand::Mods` 同款判据):
// Windows 上进程占着世界目录 ⇒ rename/删除会撞共享冲突;**Linux 上删一个正在跑的世界
// 会成功**——那是在用户正在玩的时候把存档删掉。所以判据放在 host 这一侧、落 core 之前,
// 在飞就回一条结构化失败「游戏正在运行,先退出再操作」,一个字节都不碰盘。
// 读路径与「打开文件夹」不受影响(它们不改盘)。
AppCommand::Saves(command) => { 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 未落地前用脚本回复(不接真实模型) // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
+86 -143
View File
@@ -8,6 +8,7 @@
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 | //! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) | //! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) | //! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
//! //!
//! ## 三个写操作的回程形状 //! ## 三个写操作的回程形状
//! //!
@@ -20,6 +21,22 @@
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。 //! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。 //! 回执只是"这次动的是哪一条",与快照一起回去,所以 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()` //! ## 为什么不复用启动路径的 `run_dir()`
//! //!
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `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::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir; 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 unlucky_core::platform::opener;
use crate::dto::{ use crate::dto::{
@@ -63,6 +80,8 @@ use crate::dto::{
use super::env::HostEnv; use super::env::HostEnv;
use super::job::{spawn, JobSender}; use super::job::{spawn, JobSender};
mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 /// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report { 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` 的唯一入口)。 /// 一条指令 → 后台线程(`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 { match command {
SavesCommand::List(id) => spawn_list(env, tx, id), SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(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::RenameWorld { id, dir, to } => write::spawn_rename(env, tx, id, dir, to),
SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir), SavesCommand::DeleteWorld { id, dir } => write::spawn_delete(env, tx, id, dir),
SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest), SavesCommand::BackupWorld { id, dir, dest } => write::spawn_backup(env, tx, id, dir, dest),
} }
} }
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。 /// 写操作被「游戏在跑」挡下时的那条失败(**结构化,带世界名与操作**,文案由 app 层现求)。
pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) { fn blocked(instance: &str, dir: &str, op: SavesFailureOp) -> SavesFailureView {
spawn("saves-rename", move || { let mut failure = failure(instance, SavesFailureKind::GameRunning, String::new());
let report = Report { failure.file = dir.to_string();
instance: id.clone(), failure.op = Some(op);
list: rename(&env, &id, &dir, &to), failure
};
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)));
});
} }
/// 列目录(后台线程里调)。 /// 列目录(后台线程里调)。
@@ -156,7 +189,7 @@ fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。 /// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
/// ///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。 /// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
fn snapshot( pub(super) fn snapshot(
instance: &InstanceSummary, instance: &InstanceSummary,
run_dir: &std::path::Path, run_dir: &std::path::Path,
last_op: Option<SavesOped>, last_op: Option<SavesOped>,
@@ -165,74 +198,6 @@ fn snapshot(
Ok(view(&instance.id, &scan, last_op)) 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> { fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?; 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 instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else { let Some(instance) = locate(&instances, id) else {
return Err(failure( return Err(failure(
@@ -270,7 +238,7 @@ fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFa
} }
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 /// 实例的运行目录(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 global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id) let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?; .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 { SavesFailureView {
instance: instance.to_string(), instance: instance.to_string(),
file: String::new(), 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)] #[cfg(test)]
mod tests; mod tests;
+36 -167
View File
@@ -6,6 +6,10 @@
//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断 //! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断
//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住, //! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住,
//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。 //! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。
//!
//! 「游戏在跑就拒」(M247)由 [`write_commands_do_not_touch_the_disk_while_the_game_is_running`]
//! 从 **`dispatch` 那一层**验:三条写命令一条都不落 core(盘上指纹前后一致 + 备份落点没被建),
//! 并且回一条 `GameRunning` 的具名失败。
use super::*; use super::*;
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind}; use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
@@ -204,175 +208,40 @@ fn every_core_skip_reason_keeps_its_own_kind() {
} }
} }
/// **端到端**:重命名 → 真盘改名 + 新快照 + 回执。 /// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表:「这一拍有没有写盘」的判据本体。
/// fn fingerprint(root: &std::path::Path) -> Vec<String> {
/// 抓得住:host 用错运行目录、写完成却忘了重新列(界面还画旧名字)、回执与快照对不上。 let mut out = Vec::new();
#[test] let mut stack = vec![root.to_path_buf()];
fn rename_round_trips_on_a_real_root() { while let Some(dir) = stack.pop() {
let (_root, env, run) = fixture("saves-rename"); let Ok(read) = std::fs::read_dir(&dir) else {
world(&run, "Old World"); continue;
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"); for entry in read.flatten() {
assert!( let path = entry.path();
file.starts_with("My World-"), let Ok(meta) = std::fs::symlink_metadata(&path) else {
"文件名要带世界名与时戳:{file}" continue;
);
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(),
}; };
let failure = write_failure(ID, SavesFailureOp::Backup, "Taken", &error); let rel = path
assert_eq!(failure.kind, expected, "{core:?}"); .strip_prefix(root)
assert!( .unwrap_or(&path)
failure.detail.contains("saves"), .display()
"原文丢了:{}", .to_string();
failure.detail out.push(format!(
); "{rel}|dir={}|len={}|mtime={}",
assert_eq!(failure.instance, ID); meta.is_dir(),
assert_eq!(failure.file, "Taken", "失败那句话要指名是哪一行"); meta.len(),
assert_eq!(failure.op, Some(SavesFailureOp::Backup)); 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
}
/// 找不到实例的写操作 ⇒ 同一个具名失败(不是"写成功了个空的")。 mod write;
#[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");
}
}
@@ -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::pending`] | 哪个世界的哪个写操作在飞 | 与 [`super::mods::ModsState::pending`] **同一条先例**:只用来把那一行的按钮置灰,**不做乐观回显**(列表只跟快照走) |
//! | [`SavesState::confirm`] | 删除确认框**正在等谁** | 模态队列只画框、不记得"确认之后要删哪个世界";而点主按钮的那一刻是 `app/update/dialog.rs`,那里只能从状态里取 | //! | [`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`(取走即清、取消后不再有目标)。
//!
//! # 为什么快照住在这里而不是页面 //! # 为什么快照住在这里而不是页面
//! //!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以: //! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以: