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({
) : (
- 客户端运行画面尚未载入
- 发送 /run 或 /preview 后将在这里直接运行游戏
+
+ {runSwitching ? '正在切换可运行版本' : '运行画面未启动'}
+
+ {runStatus || '请重新启动当前可运行版本'}
)}
-
-
-
-
{`测试切片 ${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());
+ }
}