From d1a47c0fe786bae8e36c4b1e282e0e183b278299 Mon Sep 17 00:00:00 2001 From: menghao Date: Tue, 4 Aug 2026 15:21:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=B8=B8=E6=88=8F=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0P0/P1=E5=8F=AF=E8=BF=90=E8=A1=8C=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冻结工作台运行版本、会话、参数、资源与消息跨端合同 新增可信 Runtime 验证后的不可变可运行版本登记与快照校验 支持 Tauri 客户端选择、切换并运行历史版本快照 收紧普通预览授权边界并同步资源高亮与明确错误展示 补充前后端测试、PRD、技术方案和项目决策记录 --- .../src/agent/runtime_driver/task_start.rs | 82 +++ .../autonomous_completion_contract_tests.rs | 132 ++++ .../src-tauri/src/main.rs | 20 +- .../src-tauri/src/preview.rs | 89 ++- .../src-tauri/src/project.rs | 2 + .../src-tauri/src/project/manifest.rs | 21 + .../src/project/runnable_versions.rs | 587 ++++++++++++++++++ .../src-tauri/src/tests/project.rs | 33 + apps/ai-game-creator-shell/src/App.tsx | 7 + .../features/app-shell/WorkspaceLauncher.tsx | 4 + .../src/features/app-shell/model.ts | 1 + .../app-shell/useHomeProjectCreation.ts | 12 + apps/ai-game-creator-shell/src/styles.css | 36 +- .../src/view/project-development/index.tsx | 238 ++++--- .../resourceProjectionModel.ts | 14 +- .../appSurface/project-development.suite.ts | 266 +++++++- .../projectResourceProjectionModel.test.ts | 79 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 110 +++- .../shared-memory/decision-log.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 16 +- .../shared/src/contracts/gameCreationApp.ts | 102 +++ .../shared-contracts/src/game_creation_app.rs | 505 +++++++++++++++ 22 files changed, 2170 insertions(+), 195 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index cfe56d75b..fddd0fbac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1249,6 +1249,88 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke }), )?; } + if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" { + let manifest_before_completion = read_manifest_for_project(root)?; + let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source); + let required_by_id = required_tasks + .iter() + .map(|task| (task.id.as_str(), task)) + .collect::>(); + let mut prerequisite_ids = std::collections::BTreeSet::new(); + let mut pending_ids = vec![state.agent_id.as_str()]; + while let Some(task_id) = pending_ids.pop() { + if !prerequisite_ids.insert(task_id) { + continue; + } + let task = required_by_id + .get(task_id) + .ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?; + pending_ids.extend(task.dependencies.iter().map(String::as_str)); + } + let incomplete = required_tasks + .iter() + .filter(|required| prerequisite_ids.contains(required.id.as_str())) + .filter(|required| required.id != state.agent_id) + .filter(|required| { + manifest_before_completion + .tasks + .iter() + .find(|task| task.id == required.id) + .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed) + }) + .map(|task| task.id.as_str()) + .collect::>(); + if !incomplete.is_empty() { + return Err(format!( + "可运行版本项目完整性检查未通过:{}", + incomplete.join("、") + )); + } + let readiness_records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, "preview-readiness"), + )?); + let readiness = readiness_records + .iter() + .rev() + .find(|record| { + record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str()) + && record.parent_run_id.as_deref() == Some(parent_run_id.as_str()) + && record.status == "completed" + }) + .ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?; + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let readiness_gate = read_game_creator_agent_runtime_verification_gate( + root, + &readiness.agent_id, + &readiness.run_id, + )?; + if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke") + || readiness_gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || readiness_gate.verified_revision != Some(current_revision.revision) + { + return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string()); + } + let contract = autonomous_playtest_completion_contract_for_state_at(root, state)? + .ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?; + let receipt = read_autonomous_playtest_receipt(root, &contract)? + .ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?; + verify_autonomous_playtest_evidence_files_at(root, &receipt)?; + if receipt.revision != current_revision.revision { + return Err(format!( + "可运行版本 revision 不一致:receipt={} current={}", + receipt.revision, current_revision.revision + )); + } + register_current_runnable_game_version_at( + root, + current_revision.revision, + &receipt.agent_id, + &receipt.run_id, + &receipt.report.path, + )?; + } update_manifest_task_status_at(root, &state.agent_id, status.clone())?; append_agent_db_record( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index c38d8e71e..725bfa711 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -698,6 +698,138 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); } +#[test] +fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete( +) { + let (_temporary, root, parent_state, contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent"); + for task_id in ["publish-strategy", "publish-package"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}")); + } + let task_ids = read_manifest_for_project(&root) + .expect("read autonomous manifest") + .tasks + .into_iter() + .map(|task| task.id) + .collect::>(); + for task_id in task_ids { + if !matches!( + task_id.as_str(), + "preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package" + ) { + update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}")); + } + } + + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview readiness running"); + let readiness_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let revision = advance_game_index_revision( + &root, + &parent_state, + "可运行版本", + ); + let readiness_state = agent_runtime_state_from_task_record(&readiness_child); + mark_verification_passed(&root, &readiness_state, "game.static_smoke"); + let readiness_terminal = AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + updated_at: unix_timestamp(), + ..readiness_child + }; + append_game_creator_agent_runtime_task_record(&root, &readiness_terminal) + .expect("persist completed preview readiness record"); + project_autonomous_manifest_ready_task_terminal_at( + &root, + &agent_runtime_state_from_task_record(&readiness_terminal), + ) + .expect("project preview readiness completion"); + + update_manifest_task_status_at( + &root, + "preview-playtest", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview playtest running"); + let playtest_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); + let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let result = browser_result_fixture( + &root, + &parent_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证可运行版本".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist runnable playtest receipt"); + let playtest_terminal = AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + updated_at: unix_timestamp(), + ..playtest_child + }; + append_game_creator_agent_runtime_task_record(&root, &playtest_terminal) + .expect("persist completed preview playtest record"); + project_autonomous_manifest_ready_task_terminal_at( + &root, + &agent_runtime_state_from_task_record(&playtest_terminal), + ) + .expect("project preview playtest and register runnable version"); + + let manifest = read_manifest_for_project(&root).expect("read runnable manifest"); + assert_eq!(manifest.runnable_versions.len(), 1); + let version = &manifest.runnable_versions[0]; + assert_eq!(version.project_revision, revision); + assert_eq!( + version.created_reason, + RunnableGameVersionCreatedReason::Initial + ); + assert_eq!( + manifest.current_runnable_version_id.as_deref(), + Some(version.version_id.as_str()) + ); + assert!(root + .join(&version.artifact_path) + .join("game/index.html") + .is_file()); + for task_id in ["publish-strategy", "publish-package"] { + let status = manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status) + .unwrap_or_else(|| panic!("missing downstream task {task_id}")); + assert_ne!( + status, + &GameCreationAppTaskStatus::Completed, + "runnable registration must not wait for downstream {task_id} completion" + ); + } +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8aa4f0b35..f068b3b24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -23,20 +23,23 @@ use reqwest::header; use serde::{Deserialize, Serialize}; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, - validate_game_iteration_versions, GameCreationAgentArtifactTrace, - GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, - GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, + validate_game_iteration_versions, validate_runnable_game_versions, + GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, + GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, - ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, + GameIterationVersionResourceBinding, ProjectResourceCanvasLayout, + ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, RunnableGameVersion, + RunnableGameVersionCreatedReason, RunnableGameVersionValidation, UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + RUNNABLE_GAME_VERSION_SCHEMA_VERSION, }; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; @@ -147,6 +150,14 @@ struct LocalPreviewStatus { root: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct RunnableGameVersionLaunchResult { + manifest: GameCreationAppManifest, + version: RunnableGameVersion, + preview: LocalPreviewResult, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalGameProjectRevisionStatus { @@ -2380,6 +2391,7 @@ fn main() { open_game_creator_launcher_window, open_project_supervisor_chat_window, start_local_game_preview, + launch_local_game_runnable_version, activate_local_game_preview, stop_local_game_preview, stop_local_game_preview_if_matches, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 056d25ce3..717a5c3cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -382,6 +382,14 @@ pub(crate) fn activate_local_game_preview( pub(crate) fn start_local_game_preview_for_project( root: &Path, ) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { + start_local_game_preview_for_served_root(root, root) +} + +pub(crate) fn start_local_game_preview_for_served_root( + project_root: &Path, + served_root: &Path, +) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { + let root = project_root; if root.as_os_str().is_empty() { return Err("项目目录不能为空".to_string()); } @@ -389,7 +397,10 @@ pub(crate) fn start_local_game_preview_for_project( return Err("项目目录必须是绝对路径".to_string()); } - let game_root = root.join("game"); + if served_root.as_os_str().is_empty() || !served_root.is_absolute() { + return Err("预览产物目录无效".to_string()); + } + let game_root = served_root.join("game"); if !game_root.is_dir() { return Err(format!("游戏目录不存在:{}", game_root.display())); } @@ -409,7 +420,7 @@ pub(crate) fn start_local_game_preview_for_project( listener .set_nonblocking(true) .map_err(|error| format!("设置预览监听失败:{error}"))?; - let served_root = root.to_path_buf(); + let served_root = served_root.to_path_buf(); let (stop_sender, stop_receiver) = mpsc::channel(); thread::spawn(move || loop { @@ -443,6 +454,80 @@ pub(crate) fn start_local_game_preview_for_project( )) } +#[tauri::command] +pub(crate) fn launch_local_game_runnable_version( + project_path: String, + expected_project_id: String, + version_id: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "preview.start")?; + let _lock = acquire_project_write_lock(root, "preview.start")?; + let current_manifest = read_existing_manifest_for_project(root)?; + if current_manifest.project_id != expected_project_id.trim() { + return Err("可运行版本项目身份不一致".to_string()); + } + let version_id = version_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(current_manifest.current_runnable_version_id.clone()) + .ok_or_else(|| "当前无可运行版本".to_string())?; + + let _ = registry.stop_for_project(Some(root)); + let (_, version, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?; + let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) { + Ok(result) => result, + Err(error) => { + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(format!("可运行版本预览启动失败:{error}")); + } + }; + if let Err(error) = record_preview_state( + root, + GameCreationAppPreviewStatus::Running, + Some(preview.url.clone()), + Some(preview.port), + ) { + let _ = stop.send(()); + return Err(error); + } + if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) { + let _ = stop.send(()); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + let (preview, previous_preview) = registry.set_running(preview, stop); + if let Some(previous_preview) = previous_preview.as_ref() { + record_replaced_preview_stop(previous_preview); + } + if let Err(error) = append_preview_start_trace_step(root, &preview) { + let _ = registry.stop(); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + let (manifest, selected_version, _) = match select_current_runnable_game_version_at( + root, + expected_project_id.trim(), + &version.version_id, + ) { + Ok(selected) => selected, + Err(error) => { + let _ = registry.stop(); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + }; + Ok(RunnableGameVersionLaunchResult { + manifest, + version: selected_version, + preview, + }) +} + fn handle_preview_stream(mut stream: TcpStream, root: &Path) { // The listener is nonblocking so its accept loop can observe the stop channel. Windows may // inherit that mode on accepted sockets; switch each connection back to blocking mode before diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 1099d78e8..fb877c095 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -12,6 +12,7 @@ mod manifest; mod memory; mod resource_dependency_graph; mod resource_layout; +mod runnable_versions; mod verification; pub(crate) use agent_db::*; @@ -23,4 +24,5 @@ pub(crate) use manifest::*; pub(crate) use memory::*; pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; +pub(crate) use runnable_versions::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 5fd595ec1..704e02cd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -635,6 +635,12 @@ pub(crate) fn read_manifest(path: &Path) -> Result Result<(), String> { validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; + validate_runnable_game_versions( + &manifest.project_id, + &manifest.runnable_versions, + manifest.current_runnable_version_id.as_deref(), + ) + .map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; if existing.versions.len() > manifest.versions.len() @@ -725,6 +737,15 @@ pub(crate) fn write_manifest( { return Err("项目版本记录写入后不可修改、删除或重排".to_string()); } + if existing.runnable_versions.len() > manifest.runnable_versions.len() + || existing + .runnable_versions + .iter() + .zip(&manifest.runnable_versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("可运行版本记录写入后不可修改、删除或重排".to_string()); + } } let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs new file mode 100644 index 000000000..16820415c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs @@ -0,0 +1,587 @@ +use super::*; + +const RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION: &str = "game-creator-runnable-artifact.v1"; +const RUNNABLE_ARTIFACT_MAX_FILES: usize = 4_096; +const RUNNABLE_ARTIFACT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RunnableArtifactDescriptor { + schema_version: String, + project_id: String, + version_id: String, + project_revision: u64, + artifact_sha256: String, + created_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WrittenRunnableArtifactSnapshot { + artifact_sha256: String, + created_at: u64, +} + +fn runnable_version_id(revision: u64) -> String { + format!("runnable-r{revision}") +} + +fn runnable_version_root(root: &Path, version_id: &str) -> PathBuf { + root.join(".agent/runnable-versions").join(version_id) +} + +fn collect_runnable_artifact_files( + base: &Path, + directory: &Path, + output: &mut Vec<(String, PathBuf, u64)>, + total_bytes: &mut u64, +) -> Result<(), String> { + let metadata = + fs::symlink_metadata(directory).map_err(|_| "可运行版本源目录不存在".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("可运行版本源目录必须是普通目录".to_string()); + } + let mut entries = fs::read_dir(directory) + .map_err(|_| "读取可运行版本源目录失败".to_string())? + .collect::, _>>() + .map_err(|_| "读取可运行版本源目录失败".to_string())?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = + fs::symlink_metadata(&path).map_err(|_| "读取可运行版本源文件失败".to_string())?; + if metadata.file_type().is_symlink() { + return Err("可运行版本不允许包含符号链接".to_string()); + } + if metadata.is_dir() { + collect_runnable_artifact_files(base, &path, output, total_bytes)?; + continue; + } + if !metadata.is_file() { + return Err("可运行版本只允许包含普通文件".to_string()); + } + if metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES { + return Err("可运行版本包含超限文件".to_string()); + } + *total_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| "可运行版本总大小已溢出".to_string())?; + if *total_bytes > RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES { + return Err("可运行版本总大小超过 512 MiB".to_string()); + } + let relative = path + .strip_prefix(base) + .map_err(|_| "可运行版本源路径越界".to_string())? + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + if relative.is_empty() || relative.chars().any(char::is_control) { + return Err("可运行版本源路径无效".to_string()); + } + output.push((relative, path, metadata.len())); + if output.len() > RUNNABLE_ARTIFACT_MAX_FILES { + return Err("可运行版本文件数量超过 4096".to_string()); + } + } + Ok(()) +} + +fn read_runnable_source_file(path: &Path, expected_len: u64) -> Result, String> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(path) + .map_err(|_| "读取可运行版本源文件失败".to_string())?; + let metadata = file + .metadata() + .map_err(|_| "读取可运行版本源文件元数据失败".to_string())?; + if !metadata.is_file() || metadata.len() != expected_len { + return Err("可运行版本源文件在登记期间发生变化".to_string()); + } + let mut bytes = Vec::with_capacity(usize::try_from(expected_len).unwrap_or(0)); + file.read_to_end(&mut bytes) + .map_err(|_| "读取可运行版本源文件失败".to_string())?; + if u64::try_from(bytes.len()).ok() != Some(expected_len) { + return Err("可运行版本源文件在登记期间发生变化".to_string()); + } + Ok(bytes) +} + +fn hash_runnable_artifact_files(files: &[(String, Vec)]) -> String { + let mut hasher = Sha256::new(); + for (relative, bytes) in files { + hasher.update((relative.len() as u64).to_be_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + format!("{:x}", hasher.finalize()) +} + +fn read_runnable_artifact_files(root: &Path) -> Result)>, String> { + let mut sources = Vec::new(); + let mut total_bytes = 0; + collect_runnable_artifact_files(root, &root.join("game"), &mut sources, &mut total_bytes)?; + let assets = root.join("assets"); + if assets.exists() { + collect_runnable_artifact_files(root, &assets, &mut sources, &mut total_bytes)?; + } + sources.sort_by(|left, right| left.0.cmp(&right.0)); + sources + .into_iter() + .map(|(relative, path, expected_len)| { + read_runnable_source_file(&path, expected_len).map(|bytes| (relative, bytes)) + }) + .collect() +} + +fn write_runnable_artifact_snapshot( + root: &Path, + project_id: &str, + version_id: &str, + revision: u64, + created_at: u64, +) -> Result { + let files = read_runnable_artifact_files(root)?; + if !files.iter().any(|(path, _)| path == "game/index.html") { + return Err("项目完整性检查失败:缺少 game/index.html".to_string()); + } + let artifact_sha256 = hash_runnable_artifact_files(&files); + let versions_root = root.join(".agent/runnable-versions"); + fs::create_dir_all(&versions_root).map_err(|_| "创建可运行版本目录失败".to_string())?; + let final_root = runnable_version_root(root, version_id); + if final_root.exists() { + let final_metadata = fs::symlink_metadata(&final_root) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本目录".to_string())?; + if final_metadata.file_type().is_symlink() || !final_metadata.is_dir() { + return Err("可运行版本快照冲突:已有版本目录无效".to_string()); + } + let descriptor_path = final_root.join("version.json"); + let descriptor_metadata = fs::symlink_metadata(&descriptor_path) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?; + if descriptor_metadata.file_type().is_symlink() || !descriptor_metadata.is_file() { + return Err("可运行版本快照冲突:已有版本描述不是普通文件".to_string()); + } + let descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?, + ) + .map_err(|_| "可运行版本快照冲突:已有版本描述无效".to_string())?; + if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION + || descriptor.project_id != project_id + || descriptor.version_id != version_id + || descriptor.project_revision != revision + { + return Err("可运行版本快照冲突:已有版本身份或 revision 不一致".to_string()); + } + let existing_files = read_runnable_artifact_files(&final_root.join("artifact")) + .map_err(|error| format!("可运行版本快照冲突:{error}"))?; + let existing_sha256 = hash_runnable_artifact_files(&existing_files); + if existing_sha256 != descriptor.artifact_sha256 + || descriptor.artifact_sha256 != artifact_sha256 + { + return Err("可运行版本快照冲突:已有版本产物摘要不一致".to_string()); + } + return Ok(WrittenRunnableArtifactSnapshot { + artifact_sha256: descriptor.artifact_sha256, + created_at: descriptor.created_at, + }); + } + let stage_root = versions_root.join(format!( + ".tmp-{version_id}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let stage_artifact = stage_root.join("artifact"); + let install_result = (|| { + for (relative, bytes) in &files { + let target = stage_artifact.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|_| "创建可运行版本快照目录失败".to_string())?; + } + fs::write(&target, bytes).map_err(|_| "写入可运行版本快照失败".to_string())?; + } + let descriptor = RunnableArtifactDescriptor { + schema_version: RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + version_id: version_id.to_string(), + project_revision: revision, + artifact_sha256: artifact_sha256.clone(), + created_at, + }; + let payload = serde_json::to_string_pretty(&descriptor) + .map_err(|_| "序列化可运行版本描述失败".to_string())?; + fs::write(stage_root.join("version.json"), format!("{payload}\n")) + .map_err(|_| "写入可运行版本描述失败".to_string())?; + fs::rename(&stage_root, &final_root).map_err(|_| "安装可运行版本快照失败".to_string())?; + Ok(()) + })(); + if install_result.is_err() { + let _ = fs::remove_dir_all(&stage_root); + } + install_result.map(|()| WrittenRunnableArtifactSnapshot { + artifact_sha256, + created_at, + }) +} + +pub(crate) fn validate_runnable_game_version_artifact_at( + root: &Path, + version: &RunnableGameVersion, +) -> Result { + let version_root = runnable_version_root(root, &version.version_id); + let version_root_metadata = fs::symlink_metadata(&version_root) + .map_err(|_| "可运行版本已损坏:版本目录不存在".to_string())?; + if version_root_metadata.file_type().is_symlink() || !version_root_metadata.is_dir() { + return Err("可运行版本已损坏:版本目录无效".to_string()); + } + let descriptor_path = version_root.join("version.json"); + let metadata = fs::symlink_metadata(&descriptor_path) + .map_err(|_| "可运行版本已损坏:版本描述不存在".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("可运行版本已损坏:版本描述不是普通文件".to_string()); + } + let descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path) + .map_err(|_| "可运行版本已损坏:无法读取版本描述".to_string())?, + ) + .map_err(|_| "可运行版本已损坏:版本描述无效".to_string())?; + if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION + || descriptor.project_id != version.project_id + || descriptor.version_id != version.version_id + { + return Err("可运行版本已损坏:版本身份不一致".to_string()); + } + if descriptor.project_revision != version.project_revision { + return Err("可运行版本 revision 不一致".to_string()); + } + if descriptor.artifact_sha256 != version.artifact_sha256 + || descriptor.created_at != version.created_at + { + return Err("可运行版本已损坏:版本描述与 manifest 不一致".to_string()); + } + let artifact_root = version_root.join("artifact"); + let files = read_runnable_artifact_files(&artifact_root) + .map_err(|error| format!("可运行版本已损坏:{error}"))?; + if hash_runnable_artifact_files(&files) != version.artifact_sha256 { + return Err("可运行版本已损坏:产物摘要不一致".to_string()); + } + Ok(artifact_root) +} + +pub(crate) fn register_current_runnable_game_version_at( + root: &Path, + revision: u64, + validation_agent_id: &str, + validation_run_id: &str, + report_path: &str, +) -> Result { + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path)?; + if let Some(existing) = manifest + .runnable_versions + .iter() + .find(|version| version.project_revision == revision) + { + validate_runnable_game_version_artifact_at(root, existing)?; + return Ok(existing.clone()); + } + if manifest + .runnable_versions + .last() + .is_some_and(|version| version.project_revision >= revision) + { + return Err("可运行版本 revision 必须严格递增".to_string()); + } + let version_id = runnable_version_id(revision); + let created_at = unix_timestamp_millis(); + let snapshot = write_runnable_artifact_snapshot( + root, + &manifest.project_id, + &version_id, + revision, + created_at, + )?; + let created_at = snapshot.created_at; + let parent_version_id = manifest + .runnable_versions + .last() + .map(|version| version.version_id.clone()); + let created_reason = if parent_version_id.is_some() { + RunnableGameVersionCreatedReason::AgentRevision + } else { + RunnableGameVersionCreatedReason::Initial + }; + let mut resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: asset.id.clone(), + resource_id: asset.id.clone(), + }) + .collect::>(); + resource_bindings.sort_by(|left, right| left.slot_id.cmp(&right.slot_id)); + let version = RunnableGameVersion { + schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(), + version_id: version_id.clone(), + project_id: manifest.project_id.clone(), + parent_version_id, + project_revision: revision, + artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"), + artifact_sha256: snapshot.artifact_sha256, + entry_path: "game/index.html".to_string(), + resource_bindings, + created_reason, + validation: RunnableGameVersionValidation { + static_smoke_passed: true, + preview_validate_passed: true, + playtest_passed: true, + agent_id: validation_agent_id.to_string(), + run_id: validation_run_id.to_string(), + report_path: report_path.to_string(), + }, + created_at, + }; + manifest.runnable_versions.push(version.clone()); + manifest.current_runnable_version_id = Some(version.version_id.clone()); + write_manifest(&manifest_path, &manifest)?; + // manifest 是可运行版本的唯一事实源;快照和 manifest 已提交后,审计写入失败 + // 不能再把一次成功登记伪装成失败,否则重试会看到版本存在却缺少首轮成功结果。 + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.runnable_version.registered", + "versionId": version.version_id, + "projectRevision": version.project_revision, + "parentVersionId": version.parent_version_id, + "artifactSha256": version.artifact_sha256, + "validationAgentId": validation_agent_id, + "validationRunId": validation_run_id, + }), + ); + Ok(version) +} + +pub(crate) fn select_current_runnable_game_version_at( + root: &Path, + expected_project_id: &str, + version_id: &str, +) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> { + let (mut manifest, version, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id, version_id)?; + let manifest_path = root.join(".agent/manifest.json"); + manifest.current_runnable_version_id = Some(version.version_id.clone()); + write_manifest(&manifest_path, &manifest)?; + Ok((manifest, version, artifact_root)) +} + +pub(crate) fn resolve_runnable_game_version_at( + root: &Path, + expected_project_id: &str, + version_id: &str, +) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> { + let manifest_path = root.join(".agent/manifest.json"); + let manifest = read_manifest(&manifest_path)?; + if manifest.project_id != expected_project_id { + return Err("可运行版本项目身份不一致".to_string()); + } + let version = manifest + .runnable_versions + .iter() + .find(|version| version.version_id == version_id) + .cloned() + .ok_or_else(|| "当前无可运行版本".to_string())?; + let artifact_root = validate_runnable_game_version_artifact_at(root, &version)?; + Ok((manifest, version, artifact_root)) +} + +fn unix_timestamp_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runnable_version_test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-runnable-version-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + fn init_runnable_version_test_project(label: &str) -> PathBuf { + let root = runnable_version_test_root(label); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write(root.join("game/index.html"), "
revision one
") + .expect("write game entry"); + fs::write(root.join("assets/player.txt"), "player one").expect("write game asset"); + let manifest = new_game_creation_app_manifest( + format!("project-{label}"), + format!("可运行版本测试 {label}"), + ); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write project manifest"); + root + } + + fn register_test_version(root: &Path, revision: u64) -> RunnableGameVersion { + register_current_runnable_game_version_at( + root, + revision, + "program-agent", + &format!("run-{revision}"), + &format!( + ".agent/runtime/browser-validations/program-agent/run-{revision}/1/validation.json" + ), + ) + .expect("register runnable version") + } + + #[test] + fn runnable_versions_register_idempotently_and_keep_immutable_snapshots() { + let root = init_runnable_version_test_project("register"); + let first = register_test_version(&root, 1); + assert_eq!(first.version_id, "runnable-r1"); + assert_eq!(first.parent_version_id, None); + assert_eq!( + first.created_reason, + RunnableGameVersionCreatedReason::Initial + ); + assert_eq!( + fs::read_to_string( + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html") + ) + .expect("read first snapshot"), + "
revision one
" + ); + + fs::write(root.join("game/index.html"), "
revision two
") + .expect("update working tree"); + fs::write(root.join("assets/player.txt"), "player two").expect("update working tree asset"); + assert_eq!(register_test_version(&root, 1), first); + + let second = register_test_version(&root, 2); + assert_eq!(second.parent_version_id.as_deref(), Some("runnable-r1")); + assert_eq!( + second.created_reason, + RunnableGameVersionCreatedReason::AgentRevision + ); + assert_ne!(second.artifact_sha256, first.artifact_sha256); + assert_eq!( + fs::read_to_string( + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html") + ) + .expect("read unchanged first snapshot"), + "
revision one
" + ); + + let manifest = + read_manifest(&root.join(".agent/manifest.json")).expect("read registered manifest"); + assert_eq!(manifest.runnable_versions, vec![first, second.clone()]); + assert_eq!( + manifest.current_runnable_version_id.as_deref(), + Some(second.version_id.as_str()) + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_snapshot_retry_reuses_original_descriptor_timestamp() { + let root = init_runnable_version_test_project("orphan-retry"); + let project_id = "project-orphan-retry"; + let first = write_runnable_artifact_snapshot(&root, project_id, "runnable-r1", 1, 100) + .expect("write orphan snapshot"); + let retried = write_runnable_artifact_snapshot(&root, project_id, "runnable-r1", 1, 200) + .expect("reuse orphan snapshot"); + assert_eq!(retried, first); + assert_eq!(retried.created_at, 100); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_version_validation_rejects_digest_and_revision_tampering() { + let root = init_runnable_version_test_project("tamper"); + let version = register_test_version(&root, 1); + let snapshot_entry = + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html"); + fs::write(&snapshot_entry, "
tampered
").expect("tamper snapshot"); + let error = validate_runnable_game_version_artifact_at(&root, &version) + .expect_err("reject digest tampering"); + assert!(error.contains("产物摘要不一致"), "{error}"); + + fs::write(&snapshot_entry, "
revision one
").expect("restore snapshot"); + let descriptor_path = root.join(".agent/runnable-versions/runnable-r1/version.json"); + let mut descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path).expect("read descriptor"), + ) + .expect("parse descriptor"); + descriptor.project_revision = 2; + fs::write( + &descriptor_path, + format!( + "{}\n", + serde_json::to_string_pretty(&descriptor).expect("serialize descriptor") + ), + ) + .expect("tamper descriptor revision"); + let error = validate_runnable_game_version_artifact_at(&root, &version) + .expect_err("reject revision tampering"); + assert_eq!(error, "可运行版本 revision 不一致"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_version_selection_serves_snapshot_and_manifest_history_is_append_only() { + let root = init_runnable_version_test_project("select"); + let version = register_test_version(&root, 1); + fs::write(root.join("game/index.html"), "
unverified edit
") + .expect("edit working tree after registration"); + + let (selected_manifest, selected, artifact_root) = + select_current_runnable_game_version_at(&root, "project-select", &version.version_id) + .expect("select registered version"); + assert_eq!(selected, version); + assert_eq!( + fs::read_to_string(artifact_root.join("game/index.html")) + .expect("read selected snapshot"), + "
revision one
" + ); + assert_eq!( + selected_manifest.current_runnable_version_id.as_deref(), + Some("runnable-r1") + ); + + let manifest_path = root.join(".agent/manifest.json"); + let stable_payload = fs::read(&manifest_path).expect("read stable manifest"); + let mut mutated = selected_manifest; + mutated.runnable_versions[0].artifact_sha256 = "b".repeat(64); + let error = write_manifest(&manifest_path, &mutated) + .expect_err("reject mutation of registered version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read unchanged manifest"), + stable_payload + ); + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 48305afff..2f638d6bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3221,6 +3221,39 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_tree() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-snapshot", "快照预览测试").expect("project init"); + fs::write(root.join("game/index.html"), "
working tree
") + .expect("write working tree game"); + let snapshot_root = root.join(".agent/runnable-versions/runnable-r1/artifact"); + fs::create_dir_all(snapshot_root.join("game")).expect("create snapshot game directory"); + fs::create_dir_all(snapshot_root.join("assets")).expect("create snapshot assets directory"); + fs::write( + snapshot_root.join("game/index.html"), + "
immutable snapshot
", + ) + .expect("write snapshot game"); + + let (preview, stop) = start_local_game_preview_for_served_root(&root, &snapshot_root) + .expect("snapshot preview start"); + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + + assert!(response.contains("200 OK"), "{response}"); + assert!(response.contains("immutable snapshot"), "{response}"); + assert!(!response.contains("working tree"), "{response}"); + assert_eq!(preview.root, root.to_string_lossy()); + + let _ = stop.send(()); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_server_drains_split_browser_headers_before_response() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7d2652110..82c57a8a6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -566,6 +566,7 @@ type AppProps = { gameChatOnly?: boolean; initialSupervisorMessage?: string; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -580,6 +581,7 @@ export function App({ gameChatOnly = false, initialSupervisorMessage = '', onPreviewChange, + onManifestChange, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -609,6 +611,11 @@ export function App({ const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); + useEffect(() => { + if (projectSupervisorOnly) { + onManifestChange?.(manifest); + } + }, [manifest, onManifestChange, projectSupervisorOnly]); const [projectStatus, setProjectStatus] = useState( eagerSupervisorProject ? '已初始化' : '未初始化', ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..f4f88dc1b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -51,6 +51,7 @@ export function WorkspaceLauncherShell({ setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, + updateCurrentProjectManifest, resetLauncherHomeDraft, createHomeDraft, openProject, @@ -157,6 +158,8 @@ export function WorkspaceLauncherShell({ agentResults={activeProjectAgentResults} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} + onManifestChange={updateCurrentProjectManifest} + onPreviewChange={setActiveProjectPreview} supervisor={ void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..af7ce9a9a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -2,6 +2,7 @@ import { type Dispatch, type FormEvent, type SetStateAction, + useCallback, useState, } from 'react'; @@ -96,6 +97,16 @@ export function useHomeProjectCreation({ rememberRecentWorkspace(context.projectPath); } + const updateCurrentProjectManifest = useCallback((manifest: GameCreationAppManifest) => { + setCurrentProjectContext((current) => + current && current.manifest.projectId === manifest.projectId + ? current.manifest === manifest + ? current + : { ...current, manifest } + : current, + ); + }, []); + async function importHomeAttachments( invoke: TauriInvoke, nextProjectPath: string, @@ -436,6 +447,7 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + updateCurrentProjectManifest, pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d80e496b0..3d311811c 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4038,6 +4038,14 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 14%); } +.game-resource-card.is-current-version { + border-color: #c85f31; + background: #fff7f1; + box-shadow: + 0 8px 22px rgb(195 105 62 / 18%), + inset 0 0 0 2px rgb(216 115 66 / 16%); +} + .game-resource-card-icon { display: grid; grid-row: 1 / 4; @@ -4423,6 +4431,25 @@ iframe.preview-frame { background: #fffdfa; } +.game-run-version-picker { + display: inline-flex; + align-items: center; + gap: 6px; + color: #76574a; + font-size: 10px; + font-weight: 700; +} + +.game-run-version-picker select { + min-width: 190px; + height: 30px; + border: 1px solid #e4cfc4; + border-radius: 9px; + background: #fff; + color: #65483d; + font-size: 10px; +} + .game-run-preview { position: relative; display: grid; @@ -4497,10 +4524,17 @@ iframe.preview-frame { .game-run-panels { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 10px; } +.game-run-status { + margin: 0; + color: #8b634f; + font-size: 10px; + text-align: center; +} + .game-run-panels > section { display: grid; align-content: start; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 11b813a51..f2ae126da 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1,6 +1,4 @@ import { - ChevronLeft, - ChevronRight, FileText, FolderTree, Gamepad2, @@ -8,11 +6,8 @@ import { Info, ListFilter, Music2, - Pause, - Play, Search, Settings2, - SlidersHorizontal, Sparkles, X, } from 'lucide-react'; @@ -35,7 +30,9 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, ProjectResourceCanvasLayoutMode, + RunnableGameVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../app/tauri'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, @@ -150,6 +147,18 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; + onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; +}; + +type RunnableGameVersionLaunchResult = { + manifest: GameCreationAppManifest; + version: RunnableGameVersion; + preview: { + url: string; + port: number; + root: string; + }; }; const categoryOrder: ResourceCategory[] = [ @@ -354,6 +363,7 @@ function summarizeAgent( const ResourceCard = memo(function ResourceCard({ resource, selected, + currentVersion, relationState, x, y, @@ -361,6 +371,7 @@ const ResourceCard = memo(function ResourceCard({ }: { resource: ProjectResource; selected: boolean; + currentVersion: boolean; relationState: 'version-binding' | null; x: number; y: number; @@ -371,6 +382,8 @@ const ResourceCard = memo(function ResourceCard({ + ) : mode === 'run' && currentRunnableVersion ? ( + ) : null} @@ -1044,7 +1148,7 @@ export default function ProjectDevelopmentView({ className="game-run-unavailable" role="status" > - 首个可运行原型尚未完成,运行视图暂不可用 + {runStatus || '当前无可运行版本'}

) : null} @@ -1389,6 +1493,10 @@ export default function ProjectDevelopmentView({ key={resource.id} resource={resource} selected={resource.id === selectedResourceId} + currentVersion={ + resource.version?.versionId === + currentRunnableVersion?.versionId + } relationState={relationState} x={position.x} y={position.y} @@ -1417,92 +1525,44 @@ export default function ProjectDevelopmentView({ ) : (
)} -
- - - {`测试切片 ${activeSlice + 1}`} - -
-
+
- {selectedResource ? ( + {currentRunnableVersion ? (
-
名称
-
{selectedResource.label}
+
版本
+
{currentRunnableVersion.versionId}
-
路径
-
{selectedResource.path}
+
修订
+
{currentRunnableVersion.projectRevision}
-
类型
-
{selectedResource.mediaType}
+
验证
+
静态检查与交互试玩均通过
) : ( -

暂停后选择资源可查看已登记信息

+

当前无可运行版本

)}
-
-
-
- - - -
+ {runStatus ? ( +

+ {runStatus} +

+ ) : null} )} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index 6cc98c8e5..c021ccfe6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -1,7 +1,7 @@ import type { GameCreationAppManifest, - GameIterationVersion, ProjectResourceCanvasSection, + RunnableGameVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ProjectResourceCategory = ProjectResourceCanvasSection; @@ -23,7 +23,7 @@ export type ProjectAgentResultSummary = { updatedAt: number; }; -export type ProjectVersionResourceSummary = GameIterationVersion & { +export type ProjectVersionResourceSummary = RunnableGameVersion & { label: string; childVersionIds: string[]; }; @@ -231,7 +231,7 @@ export function projectResourcesFromReadModels( } const childVersionIdsByParent = new Map(); - for (const version of manifest.versions ?? []) { + for (const version of manifest.runnableVersions ?? []) { if (!version.parentVersionId) { continue; } @@ -239,7 +239,9 @@ export function projectResourcesFromReadModels( children.push(version.versionId); childVersionIdsByParent.set(version.parentVersionId, children); } - for (const [index, manifestVersion] of (manifest.versions ?? []).entries()) { + for (const [index, manifestVersion] of ( + manifest.runnableVersions ?? [] + ).entries()) { const version: ProjectVersionResourceSummary = { ...manifestVersion, label: `版本 ${index + 1}`, @@ -251,8 +253,8 @@ export function projectResourcesFromReadModels( category: 'version', subtype: 'project-version', label: version.label, - path: `项目版本 · ${version.versionId}`, - mediaType: '正式项目版本', + path: `可运行版本 · ${version.versionId}`, + mediaType: '正式可运行版本', sourceLabel: version.parentVersionId ? `项目修订 ${version.projectRevision} · 父版本 ${version.parentVersionId}` : `项目修订 ${version.projectRevision} · 初始版本`, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 8dbd13a61..090b14a70 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1,6 +1,7 @@ import type { ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, + RunnableGameVersion, } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { consumeInitialGameChatMessage, @@ -56,6 +57,36 @@ import { within, } from './harness'; +function runnableVersionFixture( + projectId: string, + versionId: string, + projectRevision: number, + overrides: Partial = {}, +): RunnableGameVersion { + return { + schemaVersion: 'game-creator-runnable-version.v1', + versionId, + projectId, + parentVersionId: null, + projectRevision, + artifactPath: `.agent/runnable-versions/${versionId}/artifact`, + artifactSha256: 'a'.repeat(64), + entryPath: 'game/index.html', + resourceBindings: [], + createdReason: 'initial', + validation: { + staticSmokePassed: true, + previewValidatePassed: true, + playtestPassed: true, + agentId: 'program-agent', + runId: `run-${projectRevision}`, + reportPath: `.agent/runtime/browser-validations/program-agent/run-${projectRevision}/1/validation.json`, + }, + createdAt: projectRevision * 100, + ...overrides, + }; +} + function resourceGraphForInputs(args?: Record) { const resources = (args?.resources as @@ -647,7 +678,7 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(runTab); expect(runTab.getAttribute('aria-selected')).toBe('false'); expect( - screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'), + screen.getByText('当前无可运行版本'), ).not.toBeNull(); expect(screen.getByLabelText('附件导入失败')).not.toBeNull(); expect(screen.getByText('broken-reference.png')).not.toBeNull(); @@ -693,27 +724,20 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }, ]; - manifest.versions = [ - { - versionId: 'version-root', - parentVersionId: null, - projectRevision: 3, + manifest.runnableVersions = [ + runnableVersionFixture('workbench-versions', 'version-root', 3, { resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }], - createdReason: 'initial', - createdAt: 100, - }, - { - versionId: 'version-child', + }), + runnableVersionFixture('workbench-versions', 'version-child', 4, { parentVersionId: 'version-root', - projectRevision: 4, resourceBindings: [ { slotId: 'player', resourceId: 'asset-player' }, { slotId: 'historical', resourceId: 'asset-removed' }, ], createdReason: 'agent-revision', - createdAt: 200, - }, + }), ]; + manifest.currentRunnableVersionId = 'version-child'; render( React.createElement(ProjectDevelopmentView, { @@ -1838,6 +1862,13 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error('missing code-prototype seed task'); } codePrototype.status = 'completed'; + const runnableVersion = runnableVersionFixture( + 'workbench-runnable', + 'runnable-r7', + 7, + ); + manifest.runnableVersions = [runnableVersion]; + manifest.currentRunnableVersionId = runnableVersion.versionId; manifest.preview = { status: 'running', url: 'http://127.0.0.1:4173', @@ -1868,6 +1899,22 @@ export function registerProjectWorkbenchFoundationTests() { 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', }; } + if (command === 'launch_local_game_runnable_version') { + expect(args).toEqual({ + projectPath: '/tmp/workbench-runnable', + expectedProjectId: 'workbench-runnable', + versionId: 'runnable-r7', + }); + return { + manifest, + version: runnableVersion, + preview: { + url: 'http://127.0.0.1:4173', + port: 4173, + root: '/tmp/workbench-runnable', + }, + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -1893,16 +1940,26 @@ export function registerProjectWorkbenchFoundationTests() { expect(runTab.disabled).toBe(false); fireEvent.click(runTab); expect(screen.getByLabelText('运行表现层')).not.toBeNull(); - const previewFrame = screen.getByTitle( + const previewFrame = (await screen.findByTitle( '可运行工作台 游戏运行画面', - ) as HTMLIFrameElement; + )) as HTMLIFrameElement; expect(previewFrame.getAttribute('src')).toBe('http://127.0.0.1:4173/'); expect(previewFrame.getAttribute('sandbox')).toBe( 'allow-scripts allow-same-origin allow-forms allow-pointer-lock', ); - expect(screen.getByLabelText('测试切片控件')).not.toBeNull(); - expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); - expect(screen.getByLabelText('数值微调面板')).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'launch_local_game_runnable_version', + { + projectPath: '/tmp/workbench-runnable', + expectedProjectId: 'workbench-runnable', + versionId: 'runnable-r7', + }, + ); + }); + expect(screen.getByLabelText('可运行版本信息')).not.toBeNull(); + expect(screen.queryByLabelText('测试切片控件')).toBeNull(); + expect(screen.queryByLabelText('数值微调面板')).toBeNull(); fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click(screen.getByRole('button', { name: '按类型' })); @@ -1930,6 +1987,166 @@ export function registerProjectWorkbenchFoundationTests() { ); }); + it('unloads the old iframe before switching to another runnable version snapshot', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-version-switch', + '版本切换测试', + ); + const first = runnableVersionFixture( + manifest.projectId, + 'runnable-r1', + 1, + ); + const second = runnableVersionFixture( + manifest.projectId, + 'runnable-r2', + 2, + { + parentVersionId: first.versionId, + createdReason: 'agent-revision', + }, + ); + manifest.runnableVersions = [first, second]; + manifest.currentRunnableVersionId = first.versionId; + manifest.preview = { + status: 'running', + url: 'http://127.0.0.1:4101', + port: 4101, + }; + let resolveSecondLaunch: ((value: unknown) => void) | null = null; + const secondLaunch = new Promise((resolve) => { + resolveSecondLaunch = resolve; + }); + const invoke = vi.fn(async (command: string, args?: Record) => { + if (command !== 'launch_local_game_runnable_version') { + throw new Error(`unexpected invoke ${command}`); + } + if (args?.versionId === first.versionId) { + return { + manifest, + version: first, + preview: { + url: 'http://127.0.0.1:4101', + port: 4101, + root: '/tmp/workbench-version-switch', + }, + }; + } + return secondLaunch; + }); + window.__TAURI__ = { core: { invoke } }; + + function StatefulWorkbench() { + const [liveManifest, setLiveManifest] = React.useState(manifest); + const [preview, setPreview] = React.useState(manifest.preview ?? null); + return React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-version-switch', + manifest: liveManifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + preview, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + onManifestChange: setLiveManifest, + onPreviewChange: setPreview, + }); + } + + render(React.createElement(StatefulWorkbench)); + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + await waitFor(() => { + expect( + screen + .getByTitle('版本切换测试 游戏运行画面') + .getAttribute('src'), + ).toBe('http://127.0.0.1:4101/'); + }); + + fireEvent.change(screen.getByLabelText('当前运行版本'), { + target: { value: second.versionId }, + }); + expect(screen.queryByTitle('版本切换测试 游戏运行画面')).toBeNull(); + expect(screen.getByText('正在切换可运行版本')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith( + 'launch_local_game_runnable_version', + { + projectPath: '/tmp/workbench-version-switch', + expectedProjectId: manifest.projectId, + versionId: second.versionId, + }, + ); + + const switchedManifest = { + ...manifest, + currentRunnableVersionId: second.versionId, + preview: { + status: 'running' as const, + url: 'http://127.0.0.1:4102', + port: 4102, + }, + }; + await act(async () => { + resolveSecondLaunch?.({ + manifest: switchedManifest, + version: second, + preview: { + url: 'http://127.0.0.1:4102', + port: 4102, + root: '/tmp/workbench-version-switch', + }, + }); + await secondLaunch; + }); + await waitFor(() => { + expect( + screen + .getByTitle('版本切换测试 游戏运行画面') + .getAttribute('src'), + ).toBe('http://127.0.0.1:4102/'); + }); + }); + + it('shows a precise runnable version launch failure without embedding a preview', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-version-failure', + '版本错误测试', + ); + const version = runnableVersionFixture( + manifest.projectId, + 'runnable-r3', + 3, + ); + manifest.runnableVersions = [version]; + manifest.currentRunnableVersionId = version.versionId; + const invoke = vi.fn(async () => { + throw new Error('可运行版本 revision 不一致'); + }); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-version-failure', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + expect( + (await screen.findAllByText('可运行版本 revision 不一致')).length, + ).toBeGreaterThan(0); + expect(screen.queryByTitle('版本错误测试 游戏运行画面')).toBeNull(); + }); + it('marks an unvalidated UI prototype as a candidate image', () => { const manifest = createGameCreationAppManifest( 'workbench-ui-candidate', @@ -1984,7 +2201,7 @@ export function registerProjectWorkbenchFoundationTests() { ).not.toBeNull(); }); - it('refuses to embed a non-loopback game preview in the client workbench', () => { + it('does not treat completed code or an ordinary loopback preview as run authorization', () => { const manifest = createGameCreationAppManifest( 'workbench-remote-preview', '远程预览拒绝测试', @@ -1998,8 +2215,8 @@ export function registerProjectWorkbenchFoundationTests() { codePrototype.status = 'completed'; manifest.preview = { status: 'running', - url: 'https://example.com/game', - port: 443, + url: 'http://127.0.0.1:4188', + port: 4188, }; render( @@ -2018,7 +2235,10 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(screen.getByRole('tab', { name: '运行' })); expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull(); - expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull(); + expect(screen.getByText('当前无可运行版本')).not.toBeNull(); + expect( + screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), + ).toBe('false'); }); } diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 7f98d4175..06acb6276 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -1,8 +1,41 @@ import { describe, expect, it } from 'vitest'; -import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + type RunnableGameVersion, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; +function runnableVersionFixture( + projectId: string, + versionId: string, + projectRevision: number, + overrides: Partial = {}, +): RunnableGameVersion { + return { + schemaVersion: 'game-creator-runnable-version.v1', + versionId, + projectId, + parentVersionId: null, + projectRevision, + artifactPath: `.agent/runnable-versions/${versionId}/artifact`, + artifactSha256: 'a'.repeat(64), + entryPath: 'game/index.html', + resourceBindings: [], + createdReason: 'initial', + validation: { + staticSmokePassed: true, + previewValidatePassed: true, + playtestPassed: true, + agentId: 'program-agent', + runId: `run-${projectRevision}`, + reportPath: `.agent/runtime/browser-validations/program-agent/run-${projectRevision}/1/validation.json`, + }, + createdAt: projectRevision * 100, + ...overrides, + }; +} + describe('项目资源投影', () => { it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => { const manifest = createGameCreationAppManifest( @@ -49,18 +82,14 @@ describe('项目资源投影', () => { source: { kind: 'generated' }, }, ]; - manifest.versions = [ - { - versionId: 'version-1', - parentVersionId: null, - projectRevision: 7, + manifest.runnableVersions = [ + runnableVersionFixture('resource-projection', 'version-1', 7, { resourceBindings: [ { slotId: 'background-music', resourceId: 'registered-bgm' }, ], - createdReason: 'initial', - createdAt: 1, - }, + }), ]; + manifest.currentRunnableVersionId = 'version-1'; const resources = projectResourcesFromReadModels( manifest, @@ -137,16 +166,10 @@ describe('项目资源投影', () => { 'stable-resource-id', '稳定资源身份测试', ); - manifest.versions = [ - { - versionId: 'stable-version', - parentVersionId: null, - projectRevision: 1, - resourceBindings: [], - createdReason: 'initial', - createdAt: 1, - }, + manifest.runnableVersions = [ + runnableVersionFixture('stable-resource-id', 'stable-version', 1), ]; + manifest.currentRunnableVersionId = 'stable-version'; const first = projectResourcesFromReadModels( manifest, [], @@ -184,24 +207,14 @@ describe('项目资源投影', () => { 'version-projection', '版本投影测试', ); - manifest.versions = [ - { - versionId: 'version-root', - parentVersionId: null, - projectRevision: 2, - resourceBindings: [], - createdReason: 'initial', - createdAt: 100, - }, - { - versionId: 'version-child', + manifest.runnableVersions = [ + runnableVersionFixture('version-projection', 'version-root', 2), + runnableVersionFixture('version-projection', 'version-child', 3, { parentVersionId: 'version-root', - projectRevision: 3, - resourceBindings: [], createdReason: 'agent-revision', - createdAt: 200, - }, + }), ]; + manifest.currentRunnableVersionId = 'version-child'; const versions = projectResourcesFromReadModels(manifest, [], []).filter( (resource) => resource.category === 'version', diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d94743946..1d3d3978f 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-08-04`(依赖视图视觉口径调整) +更新时间:`2026-08-04`(可运行版本 P0/P1) ## 1. 产品定位 @@ -281,7 +281,7 @@ type UpdateProjectResourceCanvasLayoutResult = 资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。 ```ts -type ProjectResourceDescriptor = { +type GameResourceDescriptor = { resourceId: string; category: 'document' | 'version' | 'art' | 'audio'; subtype: string; @@ -305,31 +305,48 @@ type ProjectVersionResourceReplacement = { 三项兼容性必须同时为 true 才能创建下一版本。 -### 5.4 游戏迭代版本(P1) +### 5.4 可运行游戏版本(P0 合同,P1 实现) -阶段六实现状态(2026-08-03):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、预览记录或 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮,也不自动把当前编辑态登记为版本。 +`GameIterationVersion` 与 manifest `versions` 是上一阶段资源管理使用的只读迭代记录,不代表已经通过试玩,也不再作为运行入口授权。唯一可运行事实源是 `.agent/manifest.json` 中由可信 Rust / Runtime 追加的 `runnableVersions` 与可变选择指针 `currentRunnableVersionId`;前端、Agent 文本、任务完成状态、checkpoint、布局 sidecar 和普通 preview 状态均无权自行创建可运行版本。 ```ts -type GameIterationVersion = { +type RunnableGameVersion = { + schemaVersion: 'game-creator-runnable-version.v1'; versionId: string; + projectId: string; parentVersionId: string | null; projectRevision: number; + artifactPath: `.agent/runnable-versions/${string}/artifact`; + artifactSha256: string; + entryPath: 'game/index.html'; resourceBindings: Array<{ slotId: string; resourceId: string }>; - createdReason: 'initial' | 'resource-replacement' | 'agent-revision'; + createdReason: 'initial' | 'agent-revision'; + validation: { + staticSmokePassed: true; + previewValidatePassed: true; + playtestPassed: true; + agentId: string; + runId: string; + reportPath: string; + }; createdAt: number; }; type GameCreationAppManifest = { // 既有字段省略 - versions?: GameIterationVersion[]; + runnableVersions?: RunnableGameVersion[]; + currentRunnableVersionId?: string | null; }; ``` -- `versions` 按追加顺序保存。第一条必须是 `initial + parentVersionId=null`;后续记录必须引用数组中更早出现的父版本,创建原因不能再是 `initial`,从而天然排除自引用、悬空父版本和父子环。 -- `projectRevision` 与 `createdAt` 必须是 JavaScript 安全非负整数;子版本的修订必须严格大于父版本,创建时间不得早于父版本。 +- 当前 revision 只有同时满足 `preview-playtest` 传递前置任务闭包的项目完整性检查、当前 revision 的 `game.static_smoke` 通过、当前 revision 的 `preview.validate` 与真实交互试玩通过,才允许由 Runtime 登记版本。试玩后的 `publish-strategy / publish-package` 等下游交付节点不阻断可运行版本登记。首个通过版本自动登记为 `initial`;后续 Agent 修订重新完整通过后登记为 `agent-revision`。 +- 每条版本在登记时把 `game/` 与 `assets/` 复制到受保护的不可变快照目录,计算确定性 SHA-256;运行旧版本只服务该快照,不读取当前编辑工作树。启动前必须复核目录、版本描述、revision 与摘要,任何漂移都按“版本损坏”失败关闭。 +- `runnableVersions` 按追加顺序保存。第一条必须是 `initial + parentVersionId=null`;后续记录必须引用数组中更早出现的父版本并使用 `agent-revision`。同一 project revision 最多一条,重复完成投影必须幂等返回原记录。 +- `projectId` 必须等于当前 manifest,`projectRevision` 必须为 JavaScript 安全正整数;子版本 revision 严格大于父版本,创建时间不得早于父版本。 - 同一版本内 `slotId` 唯一;`resourceId` 固定保存 manifest asset ID,不保存资源卡显示名称、External Editor resource ID、路径或布局 ID。历史资源已不在当前 manifest 时仍保留原绑定,但界面不为其合成资源卡。 -- Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `versions` 前缀逐项比较;只允许追加新记录,已有记录被修改、删除或重排时写入失败且原文件保持不变。 -- 版本卡标题由稳定追加序号生成,卡片与聚焦态展示 `versionId / projectRevision / createdReason / parentVersionId`;聚焦态额外展示直接子版本和全部 slot 绑定。点击版本卡只高亮当前投影中唯一匹配 `asset:` 的资源卡,不修改版本或资源。 +- Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `runnableVersions` 前缀逐项比较;只允许追加,已有记录被修改、删除或重排时写入失败且原文件保持不变。`currentRunnableVersionId` 只能指向现存记录,允许通过专用 Tauri 命令更新。 +- 运行入口只读取当前可运行版本。切换版本时先停止当前项目旧 preview、立即卸载 iframe,再复核并启动目标快照;启动成功后回写当前选择和 loopback preview。无版本、版本损坏、版本描述 revision 不一致、权限拒绝或预览启动失败分别显示明确原因。 +- 资源管理只从 `runnableVersions` 投影“可运行版本”卡。当前选择版本的全部现存绑定资产始终高亮;点击其它版本可只读查看其父子关系和历史绑定,不改变当前运行选择。 ### 5.5 测试切片与数值参数(P2) @@ -355,9 +372,40 @@ type GameTunableParameterDefinition = { writePath: string; codeMutationAllowed: false; }; + +type GameRunSession = { + sessionId: string; + projectId: string; + versionId: string; + projectRevision: number; + status: 'starting' | 'playing' | 'paused' | 'stopped' | 'failed'; + stale: boolean; + staleReason: 'project-revision-changed' | 'version-changed' | null; + startedAt: number; + updatedAt: number; +}; + +type GameHostMessage = { + schemaVersion: 'game-creator-host-message.v1'; + sessionId: string; + sequence: number; + type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop'; + versionId: string; + projectRevision: number; +}; + +type GameRuntimeMessage = { + schemaVersion: 'game-creator-runtime-message.v1'; + sessionId: string; + sequence: number; + type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed'; + versionId: string; + projectRevision: number; + detail?: Record; +}; ``` -参数写入立即增加编辑态 revision;当前 preview/slice 保持旧 revision,并显示“需要重新拉起”。 +revision 不一致统一表示编辑工作树已经晚于当前 Session 绑定版本;Session 和切片标记 `stale`,但不热更新、不改写旧版本,也不把 stale 当失败。参数写入立即增加编辑态 revision;当前 preview/slice 保持旧 revision,并显示“需要重新拉起”。P1 尚不实现切片、参数和 Runtime message bridge。 ### 5.6 Agent 泥点归因(P2) @@ -376,23 +424,19 @@ type ProjectAgentMudPointAttribution = { ## 6. 分阶段范围 -### P0:当前实施切片 +### P0:合同冻结 -- 复用现有四区工作台壳。 -- 资源/运行切换与客户端内 loopback 预览。 -- 固定四类资源投影、只读资源画布与中央主视窗资源聚焦;资源聚焦工具栏 / 工具侧边栏及后续媒体能力暂缓。 -- Supervisor 正式会话、上传、Runtime 确认与安全错误。 -- 当前 run 专业状态与项目历史成果分离。 -- 默认三专业组,并可展开另外三组。 -- 严格审批有效;风险/无需审批可点击查看未开放原因。 -- 橙色低保真视觉与 `1280×800` 横屏边界。 +- 冻结 `RunnableGameVersion`、`GameTestSlice`、`GameRunSession`、`GameTunableParameterDefinition`、`GameResourceDescriptor`、`GameHostMessage`、`GameRuntimeMessage`。 +- 冻结 revision 不一致与 stale 语义;切片和参数属于正式 P2,技术方案不得再称为本地 UI 草稿。 +- 明确 `versions` 不是运行授权,`runnableVersions` 才是唯一权威来源。 ### P1 -- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓。 -- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。 -- 已实施正式版本只读模型、版本卡、父子关系与引用资源高亮;资源兼容性判断和不可变下一迭代版本创建仍待后续切片。 -- 美术/音频编辑状态接线。 +- Runtime 在首轮完整验证通过后自动登记首个不可变可运行版本。 +- Agent 后续修订在新 revision 完整验证通过后登记 `agent-revision` 子版本。 +- 工作台运行入口、当前版本选择、旧 preview 停止、iframe 卸载和目标快照启动全部消费正式可运行版本。 +- 当前版本绑定资源在 dependency / type 两种资源管理布局中高亮。 +- 无版本、损坏、revision 不一致和 preview 启动失败提供独立错误状态。 ### P2 @@ -436,14 +480,14 @@ type ProjectAgentMudPointAttribution = { 7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。 8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 -### 7.4 P1 正式项目版本阶段六验收 +### 7.4 P1 可运行版本验收 -1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。 -2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。 -3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。 -4. 版本聚焦态只读展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定,不提供编辑、替换、切换、回滚或运行按钮。 -5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;修改、删除或重排已有版本时写入失败,原 manifest 字节不被覆盖。 -6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。 +1. 任务完成或 `code-prototype=completed` 不能直接放行运行;只有同 revision 完整性、static smoke、preview.validate 和 playtest 全部通过后,Rust 才自动登记版本。 +2. 首版登记为 `initial`,后续新 revision 登记为 `agent-revision`;同一 revision 重放幂等,记录只允许追加且旧记录不可修改、删除或重排。 +3. 每个版本持有独立 `game/assets` 快照与摘要。当前工作树后续变化不改变旧版本;摘要、描述或 revision 被篡改时启动失败且不回退当前工作树。 +4. manifest 没有 `runnableVersions` 时点击运行显示“当前无可运行版本”;合法版本存在时运行入口启动 `currentRunnableVersionId`,不读取 `code-prototype` 状态决定可用性。 +5. 切换版本先停止旧 preview 并卸载 iframe,再启动目标 loopback 快照;失败时不继续显示旧 iframe,并给出损坏、revision 不一致或启动失败的明确原因。 +6. 当前版本卡与仍存在的绑定资产在 dependency / type 两种布局中高亮;历史已删除绑定只留在版本详情,不创建幽灵卡。 ### 7.5 阶段七完整验收 @@ -455,7 +499,7 @@ type ProjectAgentMudPointAttribution = { ## 8. 非目标 -- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但当前只读取、校验和展示已有记录。 +- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;测试切片、数值参数、Host/Runtime 消息桥与泥点归因仍属于 P2。 - 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。 - 不修改 SpacetimeDB schema。 - 不开放普通用户 Agent.md/Skill。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8bc135b27..e33a6f9af 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-08-04 工作台 P0/P1 以不可变快照建立唯一可运行版本事实源 + +- 背景:2026-08-03 资源管理阶段新增的 `manifest.versions` 只用于只读项目迭代卡,当时明确不承担运行版本切换;本轮飞书需求正式进入运行工作台 P0/P1,不能再以 `code-prototype=completed`、普通 preview、checkpoint 或前端状态推断游戏已经可运行。 +- 决策:保留 `manifest.versions` 作为通用迭代历史,新增由可信 Rust / Runtime 独占追加的 `runnableVersions` 与唯一可变选择指针 `currentRunnableVersionId`。每条记录必须绑定 project/revision、父版本、三项通过凭证、固定 entry、资源槽位和不可变 SHA-256;首版为 `initial`,后续为 `agent-revision`,已有记录在 manifest 存储边界禁止修改、删除或重排。 +- 产物边界:Runtime 只有在当前 revision 的 `preview-playtest` 传递前置任务闭包、`game.static_smoke`、`preview.validate` 与真实试玩证据全部通过后才自动登记;试玩后的发布节点不阻断登记。`game/` 与可选 `assets/` 被有界复制到 `.agent/runnable-versions//artifact`,版本描述与文件摘要在每次启动前复核。旧版本始终运行该快照,不运行当前工作树;快照已安装但 manifest 写入中断时,重试复用原描述时间与摘要,身份或内容冲突则失败关闭。 +- 工作台消费:运行入口只读取 `runnableVersions/currentRunnableVersionId`。切换时先停止旧 preview 并卸载 iframe,复核并启动目标快照,成功后才更新选择指针和 loopback preview;无版本、项目身份不符、快照损坏、revision 不一致和启动失败必须原样展示明确错误。资源管理只投影正式可运行版本,并持续高亮当前版本仍存在的绑定资产。 +- P0/P2 边界:同时冻结测试切片、运行会话、数值参数、资源描述及 Host/Runtime 消息的 TypeScript/Rust 合同;数值参数的 `codeMutationAllowed` 固定为 `false`,消息类型固定为 `host.* / runtime.*`。本轮不实现测试切片、数值微调、消息桥、泥点归因或资源替换,这些仍属于 P2。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口 - 背景:飞书资源管理需求的阶段零至阶段六已经分别完成资源卡禁拖、固定资源投影、中央聚焦、安全文档 / 媒体预览、依赖深度与正式版本只读模型;最后需要统一复核需求边界并用当前主分支完整门禁排除集成回归。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 580ed42db..7cc677d05 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -361,10 +361,10 @@ game-project/ 2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。 - 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。 -- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。 +- 中间主视窗提供 `资源管理 / 运行` 切换。2026-08-04 起运行入口不再读取 `code-prototype` 任务状态,只读取 manifest 的正式 `runnableVersions + currentRunnableVersionId`;无版本时保持视觉不可用但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。 +- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。测试切片、参数调整和 Host/Runtime message bridge 已冻结为正式 P2 合同,不再称为本地 UI 草稿,本轮不接线。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。 @@ -414,7 +414,15 @@ game-project/ 2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。 -工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。 +2026-08-04 起工作台资源投影只从 `manifest.runnableVersions` 构建可运行版本卡,按数组追加顺序生成稳定“版本 N”标题;旧 `manifest.versions` 继续保留上一阶段通用迭代历史,但不提供运行授权。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:` 卡片。当前运行版本在 dependency / type 两种布局中持续高亮仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。 + +### 可运行版本 P0/P1 + +- P0 权威合同位于工作台 PRD §5.3-§5.5 与前后端 `shared-contracts`:`RunnableGameVersion`、`GameTestSlice`、`GameRunSession`、`GameTunableParameterDefinition`、`GameResourceDescriptor`、`GameHostMessage`、`GameRuntimeMessage`。revision 不一致只产生 `stale`,不热更新旧 Session,也不原地修改旧版本。 +- P1 登记点位于自主 Runtime `preview-playtest` 成功终态投影。该点必须已经通过当前 source 中 `preview-playtest` 的传递前置任务闭包、当前 revision `game.static_smoke`、当前 revision `preview.validate`、真实 playtest 回执和证据文件复核;试玩后的发布下游节点不参与登记门禁,其它任务状态、Agent 文本或前端事件均不能登记。 +- 登记时在项目写锁内复制普通文件 `game/` 与 `assets/` 到 `.agent/runnable-versions//artifact/`,拒绝链接、越界、超限和入口缺失,生成包含 projectId / versionId / revision 的受保护描述与确定性 SHA-256,再向 `.agent/manifest.json` 追加版本。首条为 `initial`,后续为 `agent-revision`;同 revision 重放幂等。版本记录和快照都不可原地修改。 +- `currentRunnableVersionId` 是唯一可变选择指针。专用 Tauri 命令在项目锁内复核项目身份、版本结构、快照描述、revision 与摘要;切换时先停止当前项目 registry server,再启动目标快照。启动失败不得回退当前工作树或继续展示旧 iframe。 +- 普通 Agent `preview.start / preview.validate` 仍对当前编辑工作树执行,Runner registry 与 Tauri 用户可见 registry 的既有隔离不变;工作台运行专用命令只能服务已登记快照。 历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。 @@ -431,7 +439,7 @@ game-project/ - 用户能创建本地 Web 游戏项目。 - 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。 - 资源管理可在按依赖 / 按类型之间切换、搜索资源并点击打开当前资源详情;dependency 模式展示可验证的资源引用和聚合任务流,搜索过滤端点、选择高亮直接上下游。资源卡不可拖动,Pointer Move 不更新坐标、线段或手动布局;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。 -- 首个 `code-prototype` 任务未完成时运行入口不可进入并给出可感知提示;完成后可进入运行表现层,真实预览直接加载到客户端内受限运行容器。 +- manifest 没有合法 `runnableVersions + currentRunnableVersionId` 时运行入口不可进入并提示“当前无可运行版本”;只有 Runtime 为通过完整性、静态检查、preview.validate 与真实试玩的当前 revision 登记正式版本后,运行入口才从该版本的不可变快照加载受限 loopback 画面,`code-prototype` 任务状态本身不授权运行。 - 审批档位通过独立弹出面板切换,默认严格审批;界面选择不得绕过 Runtime 现有确认门禁。 - 聊天输入 `/plan` 可在普通聊天消息里查看下一轮分工计划,不读取任务文件、不启动 run、不修改项目,也不新增普通用户计划面板。 - 聊天输入 `/guide` 可在普通聊天消息里查看普通用户操作导引,不读取文件、不启动 run、不启动预览、不写项目,也不新增普通用户导引面板。 diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 40b891a51..8847be972 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -556,6 +556,106 @@ export interface GameIterationVersion { createdAt: number; } +export const RUNNABLE_GAME_VERSION_SCHEMA_VERSION = + 'game-creator-runnable-version.v1' as const; + +export type RunnableGameVersionCreatedReason = 'initial' | 'agent-revision'; + +export interface RunnableGameVersionValidation { + staticSmokePassed: true; + previewValidatePassed: true; + playtestPassed: true; + agentId: string; + runId: string; + reportPath: string; +} + +export interface RunnableGameVersion { + schemaVersion: typeof RUNNABLE_GAME_VERSION_SCHEMA_VERSION; + versionId: string; + projectId: string; + parentVersionId: string | null; + projectRevision: number; + artifactPath: string; + artifactSha256: string; + entryPath: 'game/index.html'; + resourceBindings: GameIterationVersionResourceBinding[]; + createdReason: RunnableGameVersionCreatedReason; + validation: RunnableGameVersionValidation; + createdAt: number; +} + +export type GameTestSliceStatus = + | 'idle' + | 'starting' + | 'playing' + | 'paused' + | 'completed' + | 'failed'; + +export interface GameTestSlice { + sliceId: string; + versionId: string; + title: string; + order: number; + startCondition: string; + endCondition: string; + status: GameTestSliceStatus; +} + +export interface GameRunSession { + sessionId: string; + projectId: string; + versionId: string; + projectRevision: number; + status: 'starting' | 'playing' | 'paused' | 'stopped' | 'failed'; + stale: boolean; + staleReason: 'project-revision-changed' | 'version-changed' | null; + startedAt: number; + updatedAt: number; +} + +export interface GameTunableParameterDefinition { + parameterId: string; + label: string; + valueType: 'integer' | 'number' | 'boolean' | 'enum'; + min?: number; + max?: number; + step?: number; + enumValues?: string[]; + writePath: string; + codeMutationAllowed: false; +} + +export interface GameResourceDescriptor { + resourceId: string; + category: 'document' | 'version' | 'art' | 'audio'; + subtype: string; + width?: number; + height?: number; + durationMs?: number; + format: string; +} + +export interface GameHostMessage { + schemaVersion: 'game-creator-host-message.v1'; + sessionId: string; + sequence: number; + type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop'; + versionId: string; + projectRevision: number; +} + +export interface GameRuntimeMessage { + schemaVersion: 'game-creator-runtime-message.v1'; + sessionId: string; + sequence: number; + type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed'; + versionId: string; + projectRevision: number; + detail?: Record; +} + export interface GameCreationAppManifest { schemaVersion: string; projectId: string; @@ -566,6 +666,8 @@ export interface GameCreationAppManifest { preview?: GameCreationAppPreviewState | null; commandRuns?: GameCreationAppCommandRunState[]; versions?: GameIterationVersion[]; + runnableVersions?: RunnableGameVersion[]; + currentRunnableVersionId?: string | null; } export interface GameCreationAgentToolCallTrace { diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 5da4426df..f1b75e388 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -641,6 +641,225 @@ pub struct GameIterationVersion { pub created_at: u64, } +pub const RUNNABLE_GAME_VERSION_SCHEMA_VERSION: &str = "game-creator-runnable-version.v1"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunnableGameVersionCreatedReason { + Initial, + AgentRevision, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunnableGameVersionValidation { + pub static_smoke_passed: bool, + pub preview_validate_passed: bool, + pub playtest_passed: bool, + pub agent_id: String, + pub run_id: String, + pub report_path: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunnableGameVersion { + pub schema_version: String, + pub version_id: String, + pub project_id: String, + pub parent_version_id: Option, + pub project_revision: u64, + pub artifact_path: String, + pub artifact_sha256: String, + pub entry_path: String, + pub resource_bindings: Vec, + pub created_reason: RunnableGameVersionCreatedReason, + pub validation: RunnableGameVersionValidation, + pub created_at: u64, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameTestSliceStatus { + Idle, + Starting, + Playing, + Paused, + Completed, + Failed, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameTestSlice { + pub slice_id: String, + pub version_id: String, + pub title: String, + pub order: u32, + pub start_condition: String, + pub end_condition: String, + pub status: GameTestSliceStatus, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameRunSessionStatus { + Starting, + Playing, + Paused, + Stopped, + Failed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameRunSessionStaleReason { + ProjectRevisionChanged, + VersionChanged, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameRunSession { + pub session_id: String, + pub project_id: String, + pub version_id: String, + pub project_revision: u64, + pub status: GameRunSessionStatus, + pub stale: bool, + pub stale_reason: Option, + pub started_at: u64, + pub updated_at: u64, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameTunableParameterValueType { + Integer, + Number, + Boolean, + Enum, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GameCodeMutationAllowed; + +impl Serialize for GameCodeMutationAllowed { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bool(false) + } +} + +impl<'de> Deserialize<'de> for GameCodeMutationAllowed { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if bool::deserialize(deserializer)? { + Err(serde::de::Error::custom( + "codeMutationAllowed 必须固定为 false", + )) + } else { + Ok(Self) + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameTunableParameterDefinition { + pub parameter_id: String, + pub label: String, + pub value_type: GameTunableParameterValueType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, + pub write_path: String, + pub code_mutation_allowed: GameCodeMutationAllowed, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GameResourceCategory { + Document, + Version, + Art, + Audio, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameResourceDescriptor { + pub resource_id: String, + pub category: GameResourceCategory, + pub subtype: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + pub format: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum GameHostMessageType { + #[serde(rename = "host.start")] + HostStart, + #[serde(rename = "host.pause")] + HostPause, + #[serde(rename = "host.resume")] + HostResume, + #[serde(rename = "host.stop")] + HostStop, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameHostMessage { + pub schema_version: String, + pub session_id: String, + pub sequence: u64, + #[serde(rename = "type")] + pub message_type: GameHostMessageType, + pub version_id: String, + pub project_revision: u64, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum GameRuntimeMessageType { + #[serde(rename = "runtime.ready")] + RuntimeReady, + #[serde(rename = "runtime.state")] + RuntimeState, + #[serde(rename = "runtime.completed")] + RuntimeCompleted, + #[serde(rename = "runtime.failed")] + RuntimeFailed, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameRuntimeMessage { + pub schema_version: String, + pub session_id: String, + pub sequence: u64, + #[serde(rename = "type")] + pub message_type: GameRuntimeMessageType, + pub version_id: String, + pub project_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> { if value.is_empty() || value.trim() != value { return Err(format!("{label}不能为空或包含首尾空白")); @@ -747,6 +966,138 @@ pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Re Ok(()) } +pub fn validate_runnable_game_versions( + project_id: &str, + versions: &[RunnableGameVersion], + current_version_id: Option<&str>, +) -> Result<(), String> { + if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT { + return Err(format!( + "可运行版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条" + )); + } + let mut previous_versions = HashMap::<&str, (u64, u64)>::new(); + for (index, version) in versions.iter().enumerate() { + if version.schema_version != RUNNABLE_GAME_VERSION_SCHEMA_VERSION { + return Err(format!( + "不支持的可运行版本合同:{}", + version.schema_version + )); + } + validate_iteration_version_id(&version.version_id, "可运行版本 ID", 128)?; + if previous_versions.contains_key(version.version_id.as_str()) { + return Err(format!("可运行版本 ID 重复:{}", version.version_id)); + } + if version.project_id != project_id { + return Err(format!("可运行版本 {} 不属于当前项目", version.version_id)); + } + if version.project_revision == 0 + || version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + || version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + { + return Err(format!( + "可运行版本 {} 的 revision 或创建时间无效", + version.version_id + )); + } + let expected_artifact_path = + format!(".agent/runnable-versions/{}/artifact", version.version_id); + if version.artifact_path != expected_artifact_path + || version.entry_path != "game/index.html" + || version.artifact_sha256.len() != 64 + || !version + .artifact_sha256 + .bytes() + .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value)) + { + return Err(format!( + "可运行版本 {} 的不可变产物身份无效", + version.version_id + )); + } + if !version.validation.static_smoke_passed + || !version.validation.preview_validate_passed + || !version.validation.playtest_passed + { + return Err(format!( + "可运行版本 {} 缺少完整验证凭证", + version.version_id + )); + } + validate_iteration_version_id(&version.validation.agent_id, "验证 Agent ID", 128)?; + validate_iteration_version_id(&version.validation.run_id, "验证 run ID", 128)?; + if !version + .validation + .report_path + .starts_with(".agent/runtime/browser-validations/") + || !version.validation.report_path.ends_with("/validation.json") + || version.validation.report_path.contains("..") + || version.validation.report_path.chars().any(char::is_control) + { + return Err(format!( + "可运行版本 {} 的 preview.validate 报告路径无效", + version.version_id + )); + } + + if index == 0 { + if version.parent_version_id.is_some() + || version.created_reason != RunnableGameVersionCreatedReason::Initial + { + return Err("首个可运行版本必须是无父版本的 initial 版本".to_string()); + } + } else { + if version.created_reason != RunnableGameVersionCreatedReason::AgentRevision { + return Err("后续可运行版本必须使用 agent-revision 创建原因".to_string()); + } + let Some(parent_id) = version.parent_version_id.as_deref() else { + return Err("后续可运行版本必须引用更早的父版本".to_string()); + }; + let Some((parent_revision, parent_created_at)) = previous_versions.get(parent_id) + else { + return Err(format!( + "可运行版本 {} 的父版本必须先于子版本存在", + version.version_id + )); + }; + if version.project_revision <= *parent_revision + || version.created_at < *parent_created_at + { + return Err(format!( + "可运行版本 {} 的 revision 或创建时间早于父版本", + version.version_id + )); + } + } + + if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT { + return Err(format!("可运行版本 {} 的资源绑定过多", version.version_id)); + } + let mut slot_ids = HashSet::new(); + for binding in &version.resource_bindings { + validate_iteration_version_id(&binding.slot_id, "可运行版本资源槽位 ID", 256)?; + validate_iteration_version_id(&binding.resource_id, "可运行版本资源 ID", 512)?; + if !slot_ids.insert(binding.slot_id.as_str()) { + return Err(format!( + "可运行版本 {} 的资源槽位重复:{}", + version.version_id, binding.slot_id + )); + } + } + previous_versions.insert( + version.version_id.as_str(), + (version.project_revision, version.created_at), + ); + } + + match current_version_id { + Some(current) if versions.iter().any(|version| version.version_id == current) => Ok(()), + Some(_) => Err("当前可运行版本不存在于版本 manifest".to_string()), + None if versions.is_empty() => Ok(()), + None => Err("存在可运行版本时必须选择当前运行版本".to_string()), + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameCreationAppManifest { @@ -764,6 +1115,10 @@ pub struct GameCreationAppManifest { pub command_runs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub versions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runnable_versions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_runnable_version_id: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -896,6 +1251,8 @@ pub fn new_game_creation_app_manifest( preview: None, command_runs: Vec::new(), versions: Vec::new(), + runnable_versions: Vec::new(), + current_runnable_version_id: None, } } @@ -1943,4 +2300,152 @@ mod tests { }); assert!(serde_json::from_value::(unsafe_payload).is_err()); } + + fn runnable_version_fixture( + version_id: &str, + parent_version_id: Option<&str>, + revision: u64, + created_reason: RunnableGameVersionCreatedReason, + ) -> RunnableGameVersion { + RunnableGameVersion { + schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(), + version_id: version_id.to_string(), + project_id: "project-runnable".to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision: revision, + artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"), + artifact_sha256: "a".repeat(64), + entry_path: "game/index.html".to_string(), + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason, + validation: RunnableGameVersionValidation { + static_smoke_passed: true, + preview_validate_passed: true, + playtest_passed: true, + agent_id: "program-agent".to_string(), + run_id: format!("run-{revision}"), + report_path: format!( + ".agent/runtime/browser-validations/program-agent/run-{revision}/{revision}/validation.json" + ), + }, + created_at: revision * 100, + } + } + + #[test] + fn runnable_versions_require_a_valid_append_graph_and_current_pointer() { + let root = runnable_version_fixture( + "runnable-r1", + None, + 1, + RunnableGameVersionCreatedReason::Initial, + ); + let child = runnable_version_fixture( + "runnable-r2", + Some("runnable-r1"), + 2, + RunnableGameVersionCreatedReason::AgentRevision, + ); + validate_runnable_game_versions( + "project-runnable", + &[root.clone(), child.clone()], + Some("runnable-r2"), + ) + .expect("accept valid runnable version graph"); + + assert!( + validate_runnable_game_versions( + "project-runnable", + &[root.clone(), child.clone()], + Some("missing") + ) + .expect_err("reject missing current version") + .contains("当前可运行版本不存在") + ); + + let mut invalid_child = child.clone(); + invalid_child.validation.playtest_passed = false; + assert!( + validate_runnable_game_versions( + "project-runnable", + &[root.clone(), invalid_child], + Some("runnable-r2") + ) + .expect_err("reject incomplete validation") + .contains("缺少完整验证凭证") + ); + + let mut invalid_child = child; + invalid_child.artifact_path = ".agent/runnable-versions/other/artifact".to_string(); + assert!( + validate_runnable_game_versions( + "project-runnable", + &[root, invalid_child], + Some("runnable-r2") + ) + .expect_err("reject mutable artifact identity") + .contains("不可变产物身份无效") + ); + } + + #[test] + fn workbench_p2_message_types_keep_the_frozen_wire_values() { + let host = GameHostMessage { + schema_version: "game-creator-host-message.v1".to_string(), + session_id: "session-1".to_string(), + sequence: 1, + message_type: GameHostMessageType::HostStart, + version_id: "runnable-r7".to_string(), + project_revision: 7, + }; + assert_eq!( + serde_json::to_value(host).expect("serialize host message"), + json!({ + "schemaVersion": "game-creator-host-message.v1", + "sessionId": "session-1", + "sequence": 1, + "type": "host.start", + "versionId": "runnable-r7", + "projectRevision": 7 + }) + ); + + let runtime = GameRuntimeMessage { + schema_version: "game-creator-runtime-message.v1".to_string(), + session_id: "session-1".to_string(), + sequence: 2, + message_type: GameRuntimeMessageType::RuntimeReady, + version_id: "runnable-r7".to_string(), + project_revision: 7, + detail: None, + }; + let value = serde_json::to_value(runtime).expect("serialize runtime message"); + assert_eq!(value["type"], "runtime.ready"); + assert!(value.get("detail").is_none()); + } + + #[test] + fn tunable_parameter_contract_can_never_authorize_code_mutation() { + let definition = GameTunableParameterDefinition { + parameter_id: "enemy-speed".to_string(), + label: "敌人速度".to_string(), + value_type: GameTunableParameterValueType::Number, + min: Some(0.5), + max: Some(2.0), + step: Some(0.1), + enum_values: None, + write_path: "game.balance.enemySpeed".to_string(), + code_mutation_allowed: GameCodeMutationAllowed, + }; + let value = serde_json::to_value(definition).expect("serialize tunable parameter"); + assert_eq!(value["codeMutationAllowed"], false); + assert!(value.get("enumValues").is_none()); + + let mut invalid = value; + invalid["codeMutationAllowed"] = json!(true); + assert!(serde_json::from_value::(invalid).is_err()); + } }