diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 1dfe8d837..45e2a6392 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1142,7 +1142,9 @@ pub(crate) fn set_design_agent_runtime_mode( "design.runtime-mode", )?; if active_runtime.trim() == "game" { - crate::assets::register_design_artifacts_at(root)?; + if crate::assets::register_design_artifacts_at(root)? { + advance_agent_runtime_project_revision_locked(root)?; + } } write_design_runtime_mode(root, active_runtime.trim()) } @@ -1378,6 +1380,38 @@ mod tests { let error = ensure_design_runtime_active(root).expect_err("game mode must reject design"); assert!(error.contains("游戏运行态")); } + + #[test] + fn switching_to_game_pairs_design_artifact_registration_with_revision() { + let temporary = tempfile::tempdir().expect("create runtime mode root"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换") + .expect("init project"); + fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts"); + fs::write(root.join("design_artifacts/project/design.md"), "设计内容") + .expect("write artifact"); + + let before = read_game_creator_agent_runtime_project_revision(root) + .expect("read initial revision") + .revision; + assert_eq!( + set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),) + .expect("switch to game") + .active_runtime, + "game" + ); + let after = read_game_creator_agent_runtime_project_revision(root) + .expect("read committed revision") + .revision; + assert_eq!(after, before + 1); + + set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string()) + .expect("repeat switch to game"); + let repeated = read_game_creator_agent_runtime_project_revision(root) + .expect("read repeated revision") + .revision; + assert_eq!(repeated, after); + } use serde_json::json; use std::fs; diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c4382bcef..110e35161 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at( register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source) } -pub(crate) fn register_design_artifacts_at(root: &Path) -> Result { +pub(crate) fn register_design_artifacts_at(root: &Path) -> Result { let design_root = root.join("design_artifacts"); if !design_root.exists() { - return Ok(0); + return Ok(false); } let mut files = Vec::new(); let mut directories = vec![design_root]; @@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result } } files.sort(); - let mut registered = 0; + let mut changed = false; for path in files { let relative = path .strip_prefix(root) @@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result Some("yaml" | "yml") => "text/yaml", _ => "application/octet-stream", }; - register_local_asset_at( + let (_, asset_changed) = register_local_asset_entry_with_change( root, &relative, "document", @@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result reference_resource_ids: Vec::new(), }, )?; - registered += 1; + changed |= asset_changed; } - Ok(registered) + Ok(changed) } pub(crate) fn import_canvas_asset_at( @@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { + register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source) + .map(|(result, _)| result) +} + +fn register_local_asset_entry_with_change( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, +) -> Result<(UploadLocalAssetResult, bool), String> { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; let manifest_path = root.join(".agent/manifest.json"); @@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry( let mut source_for_record = source.clone(); source_for_record.prompt = None; - let (id, record_type) = mutate_manifest_at(root, |manifest| { + let (id, record_type, changed) = mutate_manifest_at(root, |manifest| { if let Some(existing) = manifest .assets .iter_mut() @@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry( // 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified // 时才触发),于是这个资产永远停在错误栏目。 // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 + let changed = existing.kind != kind + || existing.media_type != media_type + || existing.source != source; if existing.kind != kind { existing.kind = kind.to_string(); existing.category = game_creation_app_asset_category_for_kind(kind); } existing.media_type = media_type.to_string(); existing.source = source; - Ok((existing.id.clone(), "asset.update")) + Ok((existing.id.clone(), "asset.update", changed)) } else { let id = format!( "{id_prefix}-{}-{}", @@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry( tags: Vec::new(), source, }); - Ok((id, "asset.register")) + Ok((id, "asset.register", true)) } })?; append_agent_db_record( @@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry( }), )?; - Ok(UploadLocalAssetResult { - id, - local_path: normalized_path.clone(), - absolute_path: absolute_path.to_string_lossy().into_owned(), - manifest_path: manifest_path.to_string_lossy().into_owned(), - }) + Ok(( + UploadLocalAssetResult { + id, + local_path: normalized_path.clone(), + absolute_path: absolute_path.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + }, + changed, + )) } #[derive(Clone, Debug, Deserialize)] @@ -2137,6 +2155,27 @@ mod tests { use super::*; use std::io::{Read, Write}; + #[test] + fn design_artifact_registration_reports_only_real_manifest_changes() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记") + .expect("init project"); + fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts"); + fs::write(root.join("design_artifacts/project/design.md"), "设计内容") + .expect("write artifact"); + + assert!(register_design_artifacts_at(root).expect("register first time")); + assert_eq!( + read_existing_manifest_for_project(root) + .unwrap() + .assets + .len(), + 1 + ); + assert!(!register_design_artifacts_at(root).expect("register idempotently")); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 /// /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 74e1c6273..57dba10e8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -308,6 +308,7 @@ - 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。 - 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。 +- 补充:策划项目切换到 GameAgent 时,`design_artifacts` 的新增或登记信息实际变化必须与一次项目 revision 推进配对;重复切换不重复推进,避免 manifest 已变化而 revision 仍停留在旧值,触发前端同 revision 清单冲突提示。 - 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。 - 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index 7c7eb7fdf..956aaa46f 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -342,7 +342,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide ## 14. 开发调试入口 -项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”写入 `game`。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。 +项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”先登记 `design_artifacts` 中尚未登记或登记信息已变化的策划产物,若 manifest 实际发生变化则在同一项目写锁范围内推进一次项目 revision,再写入 `game`。重复切换不重复登记或推进 revision。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。 开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。