Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs
T
suzmii 532c6d51fd
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s
完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-24 20:42:58 +08:00

1043 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use shared_contracts::game_creation_app::{
validate_project_resource_canvas_layout_revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
};
use std::collections::HashSet;
use std::fs::File;
use std::sync::{Mutex, OnceLock};
const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts";
const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock";
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
#[derive(Debug)]
struct ResourceLayoutWriteLock {
_file: File,
}
fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str {
match mode {
ProjectResourceCanvasLayoutMode::Dependency => "dependency",
ProjectResourceCanvasLayoutMode::Type => "type",
}
}
fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> String {
format!(
"{RESOURCE_LAYOUT_DIRECTORY}/{}.json",
resource_layout_mode_name(mode)
)
}
fn acquire_resource_layout_write_lock(root: &Path) -> Result<ResourceLayoutWriteLock, String> {
for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
if let Some(file) = try_open_resource_layout_write_lock_file(root)? {
return Ok(ResourceLayoutWriteLock { _file: file });
}
if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS));
}
}
Err("资源布局正在被其他窗口保存,请稍后重试".to_string())
}
#[cfg(unix)]
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
use std::ffi::CString;
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
validate_project_root(root)?;
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
let path = root.join(&relative_path);
let mut components = relative_path.split('/').collect::<Vec<_>>();
let file_name = components
.pop()
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
let mut directory = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(root)
.map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?;
for component in components {
let component =
CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?;
// SAFETY: `directory` is a live directory fd and `component` is NUL terminated.
let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) };
if created != 0 {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::AlreadyExists {
return Err(format!(
"创建资源布局锁目录失败:{}: {error}",
path.display()
));
}
}
// SAFETY: `directory` and `component` remain valid for the duration of openat.
let fd = unsafe {
libc::openat(
directory.as_raw_fd(),
component.as_ptr(),
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
)
};
if fd < 0 {
return Err(format!(
"安全打开资源布局锁目录失败:{}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
// SAFETY: openat returned a new owned fd.
directory = unsafe { File::from_raw_fd(fd) };
}
let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?;
// SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated.
let fd = unsafe {
libc::openat(
directory.as_raw_fd(),
file_name.as_ptr(),
libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
0o600,
)
};
if fd < 0 {
return Err(format!(
"安全打开资源布局锁失败:{}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
// SAFETY: openat returned a new owned fd.
let file = unsafe { File::from_raw_fd(fd) };
let metadata = file
.metadata()
.map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?;
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
let effective_user_id = unsafe { libc::geteuid() };
if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 {
return Err(format!(
"资源布局锁必须是当前用户持有的无硬链接普通文件:{}",
path.display()
));
}
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?;
let path_metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?;
if path_metadata.file_type().is_symlink()
|| path_metadata.dev() != metadata.dev()
|| path_metadata.ino() != metadata.ino()
{
return Err(format!(
"资源布局锁路径在安全打开期间发生替换:{}",
path.display()
));
}
let verified = file
.metadata()
.map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?;
if verified.uid() != effective_user_id
|| verified.nlink() != 1
|| verified.permissions().mode() & 0o777 != 0o600
{
return Err(format!(
"资源布局锁必须由当前用户持有且权限为 0600:{}",
path.display()
));
}
// SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock.
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Ok(Some(file));
}
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(format!(
"获取资源布局系统文件锁失败:{}: {error}",
path.display()
))
}
}
#[cfg(windows)]
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003;
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
validate_project_root(root)?;
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
let path = root.join(&relative_path);
let mut components = relative_path.split('/').collect::<Vec<_>>();
components
.pop()
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
let mut guarded_directories = Vec::with_capacity(components.len() + 1);
let mut current = root.to_path_buf();
for component in std::iter::once(None).chain(components.into_iter().map(Some)) {
if let Some(component) = component {
current.push(component);
if !current.exists() {
fs::create_dir(&current).map_err(|error| {
format!("创建资源布局锁目录失败:{}: {error}", current.display())
})?;
}
}
let metadata = fs::symlink_metadata(&current).map_err(|error| {
format!(
"读取资源布局锁目录元数据失败:{}: {error}",
current.display()
)
})?;
if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(format!(
"资源布局锁目录必须是普通目录且不能是 reparse point:{}",
current.display()
));
}
let handle = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ_WRITE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(&current)
.map_err(|error| {
format!("安全打开资源布局锁目录失败:{}: {error}", current.display())
})?;
let opened = handle
.metadata()
.map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?;
if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(format!(
"资源布局锁目录打开后身份无效:{}",
current.display()
));
}
guarded_directories.push(handle);
}
if let Ok(metadata) = fs::symlink_metadata(&path) {
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
{
return Err(format!(
"资源布局锁必须是普通文件且不能是 reparse point:{}",
path.display()
));
}
}
let existed = path.exists();
match fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.share_mode(0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&path)
{
Ok(file) => {
validate_windows_regular_file_handle(&file, "资源布局锁")?;
if existed {
crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?;
} else {
crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?;
}
Ok(Some(file))
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
) || matches!(error.raw_os_error(), Some(32 | 33)) =>
{
Ok(None)
}
Err(error) => Err(format!(
"获取资源布局系统文件锁失败:{}: {error}",
path.display()
)),
}
}
#[cfg(not(any(unix, windows)))]
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
Err(format!(
"当前平台不支持资源布局系统文件锁:{}",
root.join(RESOURCE_LAYOUT_LOCK_PATH).display()
))
}
fn current_resource_layout_project_id(root: &Path) -> Result<String, String> {
validate_project_root(root)?;
let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?;
let manifest = read_manifest(&manifest_path)?;
let project_id = manifest.project_id.trim();
if project_id.is_empty() {
return Err("项目 manifest 缺少 projectId".to_string());
}
Ok(project_id.to_string())
}
fn empty_resource_layout(
project_id: String,
mode: ProjectResourceCanvasLayoutMode,
) -> ProjectResourceCanvasLayout {
ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id,
mode,
revision: 0,
positions: Vec::new(),
updated_at: 0,
}
}
fn validate_resource_layout_positions(
positions: &[ProjectResourceCanvasPosition],
) -> Result<(), String> {
if positions.len() > RESOURCE_LAYOUT_MAX_POSITIONS {
return Err(format!(
"资源布局最多支持 {RESOURCE_LAYOUT_MAX_POSITIONS} 个位置"
));
}
let mut resource_ids = HashSet::with_capacity(positions.len());
for position in positions {
let resource_id = position.resource_id.trim();
if resource_id.is_empty() {
return Err("资源布局 resourceId 不能为空".to_string());
}
if resource_id.chars().count() > RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS {
return Err(format!(
"资源布局 resourceId 最多支持 {RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS} 个字符"
));
}
if resource_id.chars().any(char::is_control) {
return Err("资源布局 resourceId 不能包含控制字符".to_string());
}
if !resource_ids.insert(resource_id) {
return Err("资源布局 resourceId 不能重复".to_string());
}
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
{
return Err(format!(
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
));
}
}
Ok(())
}
fn validate_resource_layout(
layout: &ProjectResourceCanvasLayout,
project_id: &str,
mode: ProjectResourceCanvasLayoutMode,
) -> Result<(), String> {
if layout.schema_version != GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION {
return Err(format!(
"不支持的资源布局 schema:{}",
layout.schema_version
));
}
if layout.project_id != project_id {
return Err("资源布局 projectId 与当前项目不匹配".to_string());
}
if layout.mode != mode {
return Err("资源布局 mode 与文件不匹配".to_string());
}
validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?;
validate_resource_layout_positions(&layout.positions)
}
fn validate_existing_resource_layout_storage(
root: &Path,
relative_path: &str,
) -> Result<(), String> {
let path = resolve_local_project_path(root, relative_path)?;
match fs::symlink_metadata(&path) {
Ok(_) => {
let (_, metadata) = open_project_snapshot_regular_file(&path, "资源布局 sidecar")?;
if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 {
return Err(format!(
"资源布局 sidecar 超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限"
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取资源布局 sidecar 元数据失败:{}: {error}",
path.display()
));
}
}
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
match fs::symlink_metadata(&backup_path) {
Ok(_) => {
let (_, metadata) =
open_project_snapshot_regular_file(&backup_path, "资源布局恢复副本")?;
if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 {
return Err(format!(
"资源布局恢复副本超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限"
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取资源布局恢复副本元数据失败:{}: {error}",
backup_path.display()
));
}
}
Ok(())
}
fn read_resource_layout_sidecar(
root: &Path,
relative_path: &str,
) -> Result<Option<ProjectResourceCanvasLayout>, String> {
validate_existing_resource_layout_storage(root, relative_path)?;
read_agent_runtime_json_sidecar_with_max_bytes(
root,
relative_path,
"资源布局 sidecar",
RESOURCE_LAYOUT_MAX_BYTES,
)
}
pub(crate) fn read_project_resource_canvas_layout_at(
root: &Path,
mode: ProjectResourceCanvasLayoutMode,
) -> Result<ProjectResourceCanvasLayout, String> {
let project_id = current_resource_layout_project_id(root)?;
let relative_path = resource_layout_relative_path(mode);
let Some(layout) = read_resource_layout_sidecar(root, &relative_path)? else {
return Ok(empty_resource_layout(project_id, mode));
};
validate_resource_layout(&layout, &project_id, mode)?;
Ok(layout)
}
pub(crate) fn update_project_resource_canvas_layout_at(
root: &Path,
mode: ProjectResourceCanvasLayoutMode,
expected_project_id: &str,
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?;
validate_resource_layout_positions(&positions)?;
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("资源布局 expectedProjectId 不能为空".to_string());
}
let preflight_project_id = current_resource_layout_project_id(root)?;
if preflight_project_id != expected_project_id {
return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string());
}
let _lock = acquire_resource_layout_write_lock(root)?;
let project_id = current_resource_layout_project_id(root)?;
if project_id != expected_project_id {
return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string());
}
let relative_path = resource_layout_relative_path(mode);
let current = match read_resource_layout_sidecar(root, &relative_path)? {
Some(layout) => {
validate_resource_layout(&layout, &project_id, mode)?;
layout
}
None => empty_resource_layout(project_id.clone(), mode),
};
if current.revision != expected_revision {
return Ok(UpdateProjectResourceCanvasLayoutResult {
status: UpdateProjectResourceCanvasLayoutStatus::Conflict,
layout: current,
});
}
if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string());
}
let next_revision = current.revision + 1;
if current_resource_layout_project_id(root)? != project_id {
return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string());
}
let next = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id,
mode,
revision: next_revision,
positions,
updated_at: unix_millis().min(u128::from(u64::MAX)) as u64,
};
validate_existing_resource_layout_storage(root, &relative_path)?;
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&relative_path,
"资源布局 sidecar",
&next,
RESOURCE_LAYOUT_MAX_BYTES,
)?;
Ok(UpdateProjectResourceCanvasLayoutResult {
status: UpdateProjectResourceCanvasLayoutStatus::Updated,
layout: next,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, Barrier,
};
static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0);
fn unique_resource_layout_project_path() -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-resource-layout-{}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
ProjectResourceCanvasPosition {
resource_id: id.to_string(),
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
x,
y: 24,
manually_placed: true,
}
}
#[test]
fn resource_layout_missing_file_starts_at_revision_zero_and_modes_are_independent() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-project", "布局项目").expect("init project");
let dependency = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect("read dependency layout");
assert_eq!(dependency.revision, 0);
assert!(dependency.positions.is_empty());
assert!(!root
.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency
))
.exists());
let updated = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-project",
0,
vec![layout_position("asset-a", 18)],
)
.expect("update dependency layout");
assert_eq!(
updated.status,
UpdateProjectResourceCanvasLayoutStatus::Updated
);
assert_eq!(updated.layout.revision, 1);
let type_layout =
read_project_resource_canvas_layout_at(&root, ProjectResourceCanvasLayoutMode::Type)
.expect("read type layout");
assert_eq!(type_layout.revision, 0);
assert!(type_layout.positions.is_empty());
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_invalid_roots_have_zero_workbench_side_effects() {
let missing_root = unique_resource_layout_project_path();
let missing_error = update_project_resource_canvas_layout_at(
&missing_root,
ProjectResourceCanvasLayoutMode::Dependency,
"missing-project",
0,
vec![layout_position("asset-a", 10)],
)
.expect_err("missing project root must fail");
assert!(missing_error.contains("manifest") || missing_error.contains("项目"));
assert!(!missing_root.exists());
let non_project_root = unique_resource_layout_project_path();
fs::create_dir_all(&non_project_root).expect("create non-project root");
update_project_resource_canvas_layout_at(
&non_project_root,
ProjectResourceCanvasLayoutMode::Dependency,
"non-project",
0,
vec![layout_position("asset-a", 10)],
)
.expect_err("non-project root must fail");
assert!(!non_project_root.join(".agent").exists());
fs::remove_dir_all(&non_project_root).ok();
let invalid_manifest_root = unique_resource_layout_project_path();
let agent_directory = invalid_manifest_root.join(".agent");
fs::create_dir_all(&agent_directory).expect("create invalid project agent directory");
fs::write(agent_directory.join("manifest.json"), b"{invalid-json")
.expect("write invalid manifest");
update_project_resource_canvas_layout_at(
&invalid_manifest_root,
ProjectResourceCanvasLayoutMode::Dependency,
"invalid-project",
0,
vec![layout_position("asset-a", 10)],
)
.expect_err("invalid project manifest must fail");
assert!(!agent_directory.join("workbench").exists());
fs::remove_dir_all(invalid_manifest_root).ok();
}
#[test]
fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-current-project", "布局当前项目")
.expect("init project");
let error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-stale-project",
0,
vec![layout_position("asset-a", 10)],
)
.expect_err("stale project window must fail");
assert!(error.contains("expectedProjectId"));
assert!(!root.join(".agent/workbench").exists());
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() {
use std::fs::FileTimes;
use std::os::unix::fs::MetadataExt;
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-system-lock", "布局系统锁")
.expect("init project");
let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock");
let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH);
let original = fs::symlink_metadata(&lock_path).expect("stat original lock");
first
._file
.set_times(FileTimes::new().set_modified(UNIX_EPOCH))
.expect("age live lock mtime");
assert!(try_open_resource_layout_write_lock_file(&root)
.expect("try competing lock")
.is_none());
let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock");
assert_eq!(while_locked.dev(), original.dev());
assert_eq!(while_locked.ino(), original.ino());
drop(first);
let second = try_open_resource_layout_write_lock_file(&root)
.expect("try released lock")
.expect("released system lock must be acquirable");
let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock");
assert_eq!(after_reacquire.dev(), original.dev());
assert_eq!(after_reacquire.ino(), original.ino());
drop(second);
assert!(lock_path.exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_compare_and_swap_returns_latest_without_overwrite() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-cas", "布局 CAS").expect("init project");
let first = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-cas",
0,
vec![layout_position("asset-a", 10)],
)
.expect("first update");
assert_eq!(first.layout.revision, 1);
let conflict = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-cas",
0,
vec![layout_position("asset-a", 999)],
)
.expect("conflict result");
assert_eq!(
conflict.status,
UpdateProjectResourceCanvasLayoutStatus::Conflict
);
assert_eq!(conflict.layout.revision, 1);
assert_eq!(conflict.layout.positions[0].x, 10);
let persisted = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect("read persisted");
assert_eq!(persisted.positions[0].x, 10);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限")
.expect("init project");
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
let exhausted = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "layout-revision-max".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![layout_position("asset-a", 10)],
updated_at: 1,
};
let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout");
fs::write(&path, &original).expect("write exhausted layout");
let error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-revision-max",
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
vec![layout_position("asset-a", 999)],
)
.expect_err("revision exhaustion must fail");
assert!(error.contains("revision"));
assert_eq!(fs::read(&path).expect("read exhausted layout"), original);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_unsafe_revision_without_side_effects() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision")
.expect("init project");
let expected_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-unsafe-revision",
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
vec![layout_position("asset-a", 10)],
)
.expect_err("unsafe expected revision must fail");
assert!(expected_error.contains("revision"));
assert!(!root.join(".agent/workbench").exists());
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
let unsafe_payload = serde_json::json!({
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
"projectId": "layout-unsafe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 1
});
let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture");
fs::write(&path, &original).expect("write unsafe fixture");
let read_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("unsafe persisted revision must fail");
assert!(read_error.contains("revision") || read_error.contains("安全整数"));
assert_eq!(fs::read(&path).expect("read unsafe fixture"), original);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-identity", "布局身份").expect("init project");
let duplicate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-identity",
0,
vec![
layout_position("asset-a", 10),
layout_position("asset-a", 20),
],
)
.expect_err("duplicate ids must fail");
assert!(duplicate_error.contains("不能重复"));
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
let foreign = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "foreign-project".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![],
updated_at: 1,
};
fs::write(
&path,
serde_json::to_vec(&foreign).expect("serialize foreign"),
)
.expect("write foreign layout");
let identity_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("foreign project id must fail");
assert!(identity_error.contains("projectId"));
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_corrupt_unknown_and_out_of_bounds_payloads() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-invalid", "无效布局").expect("init project");
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
fs::write(&path, b"{invalid-json").expect("write corrupt layout");
let corrupt_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("corrupt layout must fail");
assert!(corrupt_error.contains("解析") || corrupt_error.contains("JSON"));
let unknown = ProjectResourceCanvasLayout {
schema_version: "game-creator-resource-layout.v999".to_string(),
project_id: "layout-invalid".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![],
updated_at: 1,
};
fs::write(
&path,
serde_json::to_vec(&unknown).expect("serialize unknown"),
)
.expect("write unknown layout");
let schema_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("unknown schema must fail");
assert!(schema_error.contains("schema"));
let boundary = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
0,
vec![layout_position(
"asset-coordinate-boundary",
RESOURCE_LAYOUT_MAX_COORDINATE,
)],
)
.expect("boundary coordinate must remain valid");
assert_eq!(
boundary.layout.positions[0].x,
RESOURCE_LAYOUT_MAX_COORDINATE
);
let lower_boundary = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
boundary.layout.revision,
vec![layout_position(
"asset-coordinate-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE,
)],
)
.expect("lower boundary coordinate must remain valid");
assert_eq!(
lower_boundary.layout.positions[0].x,
RESOURCE_LAYOUT_MIN_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate",
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
)],
)
.expect_err("oversized coordinate must fail");
assert!(coordinate_error.contains("坐标"));
let lower_coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate-below-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
)],
)
.expect_err("coordinate below lower boundary must fail");
assert!(lower_coordinate_error.contains("坐标"));
let id_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
0,
vec![layout_position(
&"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1),
10,
)],
)
.expect_err("oversized resource id must fail");
assert!(id_error.contains("resourceId"));
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn resource_layout_rejects_symlinks_and_hardlinks() {
use std::os::unix::fs::symlink;
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-links", "布局链接").expect("init project");
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
let payload = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "layout-links".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![],
updated_at: 1,
};
let bytes = serde_json::to_vec(&payload).expect("serialize layout");
let target = root.join("layout-link-target.json");
fs::write(&target, &bytes).expect("write symlink target");
symlink(&target, &path).expect("create sidecar symlink");
let symlink_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("symlink must fail");
assert!(symlink_error.contains("符号链接"));
fs::remove_file(&path).expect("remove symlink");
fs::write(&path, &bytes).expect("write sidecar");
let hardlink = root.join("layout-hardlink.json");
fs::hard_link(&path, &hardlink).expect("create hardlink");
let hardlink_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("hardlink must fail");
assert!(hardlink_error.contains("硬链接"));
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_serializes_concurrent_updates_and_hides_workbench_files() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-concurrent", "布局并发").expect("init project");
let barrier = Arc::new(Barrier::new(2));
let handles = [10, 20].map(|x| {
let root = root.clone();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-concurrent",
0,
vec![layout_position("asset-a", x)],
)
})
});
let results = handles
.into_iter()
.map(|handle| {
handle
.join()
.expect("layout update thread")
.expect("layout update")
})
.collect::<Vec<_>>();
assert_eq!(
results
.iter()
.filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Updated)
.count(),
1
);
assert_eq!(
results
.iter()
.filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Conflict)
.count(),
1
);
let relative_path =
resource_layout_relative_path(ProjectResourceCanvasLayoutMode::Dependency);
let listed = list_local_project_files_at(&root).expect("list project files");
assert!(!listed
.files
.iter()
.any(|entry| entry.path.starts_with(".agent/workbench")));
assert!(read_local_project_file_at(&root, &relative_path).is_err());
assert!(write_local_project_file_at(&root, &relative_path, "tampered").is_err());
assert!(delete_local_project_file_at(&root, &relative_path).is_err());
fs::remove_dir_all(root).ok();
}
}