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:
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)。
|
||||||
|
|||||||
@@ -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)", "游戏正在运行(先退出再操作)"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_)) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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`(取走即清、取消后不再有目标)。
|
||||||
|
//!
|
||||||
//! # 为什么快照住在这里而不是页面
|
//! # 为什么快照住在这里而不是页面
|
||||||
//!
|
//!
|
||||||
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
||||||
|
|||||||
Reference in new issue
Block a user