diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e2982bbc3..bc54417c2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", + "dev:app-run": "node scripts/start-tauri-dev.mjs --app-run", "game-chat": "node scripts/start-tauri-dev.mjs --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 91e8f5e2d..cf0288064 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,12 @@ const tauriConfig = JSON.parse( 'utf8', ), ); +const appRunTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.app-run-dev.conf.json', import.meta.url), + 'utf8', + ), +); const gameChatReleaseTauriConfig = JSON.parse( fs.readFileSync( new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url), @@ -1263,6 +1269,33 @@ if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') { ); } +if ( + packageConfig.scripts?.['dev:app-run'] !== + 'node scripts/start-tauri-dev.mjs --app-run' || + rootPackageConfig.scripts?.['agc:app-run'] !== + 'npm --prefix apps/ai-game-creator-shell run dev:app-run' +) { + throw new Error( + 'AI game creator app-run dev profile must use the managed Tauri dev launcher', + ); +} + +const appRunWindow = appRunTauriConfig.app?.windows?.[0]; +if ( + appRunTauriConfig.productName !== + 'Genarrative AI Game Creator App Run' || + appRunTauriConfig.identifier !== + 'world.genarrative.ai-game-creator.app-run' || + appRunTauriConfig.identifier === tauriConfig.identifier || + appRunTauriConfig.build?.devUrl !== 'http://127.0.0.1:3081/' || + appRunWindow?.label !== 'client' || + appRunWindow?.title !== 'AI 游戏创作 · App Run' +) { + throw new Error( + 'AI game creator app-run profile must keep an independent identity, title, and Vite port', + ); +} + if ( packageConfig.scripts?.['game-chat'] !== 'node scripts/start-tauri-dev.mjs --game-chat' diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 562e9baaf..6d15e497a 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -8,17 +8,55 @@ import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); -const viteHost = '127.0.0.1'; -const vitePort = 3080; +const devProfileName = process.env.GENARRATIVE_AGC_DEV_PROFILE || 'default'; + +function resolveDevStackProfile(name = 'default') { + switch (name) { + case 'default': + return { + name, + viteHost: '127.0.0.1', + vitePort: 3080, + apiPort: 8082, + bgfilterWorkerPort: 8083, + spacetimePort: 3101, + backendDatabase: 'genarrative-game-creator-dev', + backendSpacetimeDataDir: resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator/data', + ), + }; + case 'app-run': + return { + name, + viteHost: '127.0.0.1', + vitePort: 3081, + apiPort: 8084, + bgfilterWorkerPort: 8085, + spacetimePort: 3103, + backendDatabase: 'genarrative-game-creator-app-run-dev', + backendSpacetimeDataDir: resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator-app-run/data', + ), + }; + default: + throw new Error(`未知 AI 游戏创作开发 profile: ${name}`); + } +} + +const devProfile = resolveDevStackProfile(devProfileName); +const { + viteHost, + vitePort, + apiPort, + spacetimePort, + backendDatabase, + backendSpacetimeDataDir, +} = devProfile; const viteUrl = `http://${viteHost}:${vitePort}/`; -const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`; const defaultApiTarget = - process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; -const backendDatabase = 'genarrative-game-creator-dev'; -const backendSpacetimeDataDir = resolve( - repoRoot, - 'server-rs/.spacetimedb/ai-game-creator/data', -); + process.env.RUST_SERVER_TARGET || `http://127.0.0.1:${apiPort}`; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const childLifecycles = new WeakMap(); @@ -72,6 +110,7 @@ function resolveBackendTargetsFromState( expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, fallbackApiTarget = defaultApiTarget, + fallbackSpacetimeTarget = `http://127.0.0.1:${spacetimePort}`, } = {}, ) { const apiServer = state?.services?.['api-server']; @@ -100,7 +139,7 @@ function resolveBackendTargetsFromState( ? spacetime.url : requireAgcBackend ? '' - : 'http://127.0.0.1:3101'; + : fallbackSpacetimeTarget; return { apiUrl, spacetimeUrl, @@ -131,13 +170,24 @@ async function isBackendReady() { ); } -async function readExistingViteServer() { - return httpGetText(viteUrl); +function resolveViteProfileUrls(profile = devProfile) { + const profileViteUrl = `http://${profile.viteHost}:${profile.vitePort}/`; + return { + viteUrl: profileViteUrl, + viteMarkerUrl: `${profileViteUrl}__agc_dev_server.json`, + }; } -function isVitePortListening() { +async function readExistingViteServer(profile = devProfile) { + return httpGetText(resolveViteProfileUrls(profile).viteUrl); +} + +function isVitePortListening(profile = devProfile) { return new Promise((resolveRequest) => { - const socket = net.connect({ host: viteHost, port: vitePort }); + const socket = net.connect({ + host: profile.viteHost, + port: profile.vitePort, + }); socket.once('connect', () => { socket.destroy(); resolveRequest(true); @@ -160,8 +210,11 @@ function isAiGameCreatorServer(response) { ); } -async function readExistingViteMarker() { - const response = await httpGetText(viteMarkerUrl, 2000); +async function readExistingViteMarker(profile = devProfile) { + const response = await httpGetText( + resolveViteProfileUrls(profile).viteMarkerUrl, + 2000, + ); if (!response || response.statusCode !== 200) { return null; } @@ -173,15 +226,17 @@ async function readExistingViteMarker() { } async function preflightExistingVite({ - readServer = readExistingViteServer, - portListening = isVitePortListening, - readMarker = readExistingViteMarker, + profile = devProfile, + readServer = () => readExistingViteServer(profile), + portListening = () => isVitePortListening(profile), + readMarker = () => readExistingViteMarker(profile), } = {}) { + const { viteUrl: profileViteUrl } = resolveViteProfileUrls(profile); const existing = await readServer(); if (!existing) { if (await portListening()) { throw new Error( - `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + `${profileViteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, ); } return { status: 'available', apiTarget: '' }; @@ -189,7 +244,7 @@ async function preflightExistingVite({ if (!isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + `${profileViteUrl} is already in use by another server. Stop it before starting Tauri dev.`, ); } @@ -202,7 +257,7 @@ async function preflightExistingVite({ : ''; const actualTarget = markerApiTarget || 'unknown'; throw new Error( - `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, + `${profileViteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, ); } @@ -507,27 +562,50 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) { throw new Error('等待配套后端和数据库启动超时'); } +function buildBackendStartArguments(profile = devProfile) { + return [ + '--prefix', + '../..', + 'run', + 'agc:backend', + '--', + '--database', + profile.backendDatabase, + '--spacetime-data-dir', + profile.backendSpacetimeDataDir, + '--api-port', + String(profile.apiPort), + '--bgfilter-worker-port', + String(profile.bgfilterWorkerPort), + '--spacetime-port', + String(profile.spacetimePort), + '--no-interactive', + ]; +} + +function buildViteStartArguments(profile = devProfile) { + return [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--config', + 'vite.config.ts', + '--host', + profile.viteHost, + '--port', + String(profile.vitePort), + '--strictPort', + ]; +} + async function ensureBackend({ onBackendChild = () => {}, checkBackendReady = isBackendReady, resolveTargets = readBackendTargets, spawnBackend = () => - spawnChild( - npm, - [ - '--prefix', - '../..', - 'run', - 'agc:backend', - '--', - '--database', - backendDatabase, - '--spacetime-data-dir', - backendSpacetimeDataDir, - '--no-interactive', - ], - { cwd: appRoot }, - ), + spawnChild(npm, buildBackendStartArguments(), { cwd: appRoot }), waitUntilReady = waitForBackendReady, } = {}) { if (await checkBackendReady()) { @@ -569,11 +647,7 @@ async function startVite(apiTarget) { ); } - return spawnChild( - npm, - ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'], - { cwd: appRoot }, - ); + return spawnChild(npm, buildViteStartArguments(), { cwd: appRoot }); } async function main() { @@ -593,6 +667,9 @@ async function main() { } try { + console.log( + `[ai-game-creator-shell] dev profile ${devProfile.name}: vite=${viteUrl} api=${apiPort} spacetime=${spacetimePort}`, + ); await preflightExistingVite(); const backend = await ensureBackend({ onBackendChild(child) { @@ -650,6 +727,8 @@ function isDirectModuleExecution() { } export { + buildBackendStartArguments, + buildViteStartArguments, ensureBackend, formatChildFailure, isDirectModuleExecution, @@ -658,6 +737,7 @@ export { readChildFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, + resolveDevStackProfile, runWindowsTaskkill, spawnChild, stopChild, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 5c6bba17e..6419a9f13 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { preflightExistingVite, + resolveDevStackProfile, spawnChild, stopChild, terminateChildTree, @@ -15,24 +16,43 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); function parseLauncherArguments(argv) { const args = [...argv]; + const appRun = args[0] === '--app-run'; + if (appRun) { + args.shift(); + } const gameChat = args[0] === '--game-chat'; if (gameChat) { args.shift(); } - return { gameChat, args }; + if (appRun && gameChat) { + throw new Error('app-run profile 不能与 game-chat 入口同时使用'); + } + return { appRun, gameChat, args }; } function buildTauriArguments(argv) { - const { gameChat, args } = parseLauncherArguments(argv); + const { appRun, gameChat, args } = parseLauncherArguments(argv); + if (appRun) { + return [ + 'dev', + '--config', + 'src-tauri/tauri.app-run-dev.conf.json', + ...args, + ]; + } if (gameChat) { return ['dev', '--', '--', '--game-chat', ...args]; } return ['dev', ...args]; } -function spawnTauriCli(argv) { +function spawnTauriCli(argv, { profileName = 'default' } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, + env: { + ...process.env, + GENARRATIVE_AGC_DEV_PROFILE: profileName, + }, shell: false, }); } @@ -46,10 +66,12 @@ async function runTauriDev( terminateTree = terminateChildTree, } = {}, ) { - await preflight(); + const { appRun } = parseLauncherArguments(argv); + const profile = resolveDevStackProfile(appRun ? 'app-run' : 'default'); + await preflight({ profile }); const tauriArguments = buildTauriArguments(argv); - const child = spawnCli(tauriArguments); + const child = spawnCli(tauriArguments, { profileName: profile.name }); let resolveShutdown; let shutdownSignal = ''; let repeatedSignal = false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 5c9613bdd..56603b1b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -5710,10 +5710,22 @@ mod canvas_generation_tests { stream .set_read_timeout(Some(Duration::from_secs(10))) .expect("set request read timeout"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); let mut bytes = Vec::new(); let mut buffer = [0_u8; 4096]; loop { - let read = stream.read(&mut buffer).expect("read request bytes"); + let read = match stream.read(&mut buffer) { + Ok(read) => read, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) && std::time::Instant::now() < deadline => + { + continue; + } + Err(error) => panic!("read request bytes: {error}"), + }; if read == 0 { break; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 4ffb0d73f..a811f5085 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -204,14 +204,16 @@ fn queue_game_chat_fast_path_child( #[test] fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() { - let root = std::env::temp_dir().join(format!( - "genarrative-agent-main-loop-legacy-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let root = fs::canonicalize(std::env::temp_dir()) + .expect("canonicalize temporary root") + .join(format!( + "genarrative-agent-main-loop-legacy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁") .expect("project init"); assert!(!autonomous_registered_derived_visuals_need_repair_at(&root)); @@ -308,6 +310,14 @@ fn prepare_autonomous_completion_evidence( ("memory/project.md", "# 项目记忆\n\n正式约束。\n"), ("game/game_design.md", "# 游戏设计\n\n核心循环。\n"), ("game/balance.json", r#"{"lives":3,"speed":1}"#), + ( + "game/tunable-parameters.json", + r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#, + ), + ( + "game/tunable-values.json", + r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#, + ), ( "assets/manifest.art.json", r#"{"assets":[{"path":"assets/art-spritesheet.png"}],"sliceManifest":"assets/art-spritesheet-slices/manifest.json","status":"generated"}"#, @@ -869,6 +879,16 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() { render_game_chat_fast_path_html("制作水晶俄罗斯方块小游戏"), ) .expect("write code entry with all art slices"); + fs::write( + root.join("game/tunable-parameters.json"), + r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#, + ) + .expect("write repaired tunable parameter registry"); + fs::write( + root.join("game/tunable-values.json"), + r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#, + ) + .expect("write repaired tunable parameter values"); let mut repaired_but_failed_plan = child_state.clone(); repaired_but_failed_plan.plan_steps[0].status = AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string(); let repaired_failed_error = game_chat_fast_path_plan_at( 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 7ee37cb57..eb1eb8d0f 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 @@ -1444,6 +1444,89 @@ 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, + receipt.playtest_scenario, + )?; + } update_manifest_task_status_at(root, &state.agent_id, status.clone())?; append_agent_db_record( root, @@ -1529,8 +1612,13 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( } else { "" }; + let code_tunable_parameter_requirement = if task.id == "code-prototype" { + " 数值微调合同固定为 game/tunable-parameters.json 与 game/tunable-values.json。注册表 schemaVersion 必须为 game-creator-tunable-parameters.v1,parameters 最多 128 项;每项包含稳定 parameterId、label、valueType、defaultValue、currentValue、可选 min/max/step/enumValues/unit、固定 writePath=game/tunable-values.json#/values/、effectMode=next-relaunch、editablePhase=paused、codeMutationAllowed=false。值文件 schemaVersion 必须为 game-creator-tunable-values.v1,values 只包含已登记 parameterId。只登记当前游戏确实读取的少量数值;game/index.html 必须在每次新启动时读取值文件并应用,运行中的 iframe 不得监听或热写该文件。" + } else { + "" + }; let owner_prompt = format!( - "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}{code_tunable_parameter_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" ); return format!("{owner_prompt}{code_visual_asset_requirement}"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index f6d81dd65..645c54a64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -75,7 +75,11 @@ pub(in crate::agent) fn autonomous_manifest_owner_artifact_paths( "balance-seed" => &["game/balance.json"], "art-asset-plan" => &["assets/manifest.art.json"], "audio-asset-plan" => &["assets/manifest.audio.json"], - "code-prototype" => &[AGENT_RUNTIME_GAME_INDEX_PATH], + "code-prototype" => &[ + AGENT_RUNTIME_GAME_INDEX_PATH, + "game/tunable-parameters.json", + "game/tunable-values.json", + ], "publish-package" => &["exports/README.md"], _ => &[], } @@ -6518,7 +6522,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt( concat!( "完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 "#; +const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => { + 'use strict'; + const protocolVersion = 'game-creator-run-bridge.v1'; + const hostSchema = 'game-creator-host-message.v1'; + const runtimeSchema = 'game-creator-runtime-message.v1'; + const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request', 'host.slice.start', 'host.slice.stop']); + let session = null; + let hostOrigin = null; + let hostSequence = 0; + let runtimeSequence = 0; + let state = 'starting'; + let currentSlice = null; + const hostRequestIds = new Set(); + + const randomId = () => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return `runtime-request-${Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')}`; + }; + const sameIdentity = message => session && + message.sessionId === session.sessionId && + message.previewId === session.previewId && + message.projectId === session.projectId && + message.versionId === session.versionId && + message.projectRevision === session.projectRevision; + const send = (type, payload, responseTo) => { + if (!session || !hostOrigin) return false; + const message = { + schemaVersion: runtimeSchema, + protocolVersion, + requestId: randomId(), + sessionId: session.sessionId, + previewId: session.previewId, + projectId: session.projectId, + sequence: ++runtimeSequence, + type, + versionId: session.versionId, + projectRevision: session.projectRevision, + payload, + }; + if (responseTo) message.responseTo = responseTo; + parent.postMessage(message, hostOrigin); + return true; + }; + const reportState = (nextState, summary) => { + state = nextState; + return send('runtime.state', summary === undefined ? { status: nextState } : { status: nextState, summary }); + }; + const reportError = (code, message, recoverable = false) => + send('runtime.error', { code, message, recoverable }); + const reportSlice = (sliceId, title, status, summary) => { + if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = status; + return send('runtime.slice', summary === undefined ? { sliceId, title, status } : { sliceId, title, status, summary }); + }; + const reportInspection = (inspectionId, label, metrics, summary) => + send('runtime.inspection', summary === undefined ? { inspectionId, label, metrics } : { inspectionId, label, metrics, summary }); + const reportTargetInspection = (targetKind, targetId) => + send('runtime.inspection', { inspectionId: `hover-${targetKind}`, label: '', metrics: {}, targetKind, targetId }); + const clearInspection = () => + send('runtime.inspection', { inspectionId: 'hover-clear', label: '', metrics: {}, targetKind: 'none' }); + + Object.defineProperty(window, 'genarrativeGameBridge', { + configurable: false, + enumerable: false, + writable: false, + value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection, reportTargetInspection, clearInspection }), + }); + + addEventListener('message', event => { + const message = event.data; + if (event.source !== parent || !message || typeof message !== 'object' || + message.schemaVersion !== hostSchema || message.protocolVersion !== protocolVersion || + !hostTypes.has(message.type) || !Number.isSafeInteger(message.sequence) || + typeof message.requestId !== 'string' || hostRequestIds.has(message.requestId)) return; + if (message.type === 'host.start') { + if (session || message.sequence !== 1 || typeof message.sessionId !== 'string' || + typeof message.previewId !== 'string' || typeof message.projectId !== 'string' || + typeof message.versionId !== 'string' || !Number.isSafeInteger(message.projectRevision)) return; + hostOrigin = event.origin; + hostSequence = 1; + hostRequestIds.add(message.requestId); + session = { + sessionId: message.sessionId, + previewId: message.previewId, + projectId: message.projectId, + versionId: message.versionId, + projectRevision: message.projectRevision, + }; + send('runtime.ready', { capabilities: ['state', 'slice', 'inspection'] }, message.requestId); + state = 'playing'; + send('runtime.state', { status: state }); + dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message })); + return; + } + if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return; + if ((message.type === 'host.slice.start' || message.type === 'host.slice.stop') && + (!message.payload || typeof message.payload !== 'object' || Object.keys(message.payload).length !== 1 || + typeof message.payload.sliceId !== 'string' || !message.payload.sliceId)) return; + hostSequence = message.sequence; + hostRequestIds.add(message.requestId); + if (message.type === 'host.pause') { + state = 'paused'; + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.resume') { + state = 'playing'; + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.state.request') { + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.inspection.request') { + send('runtime.inspection', { + inspectionId: 'host-baseline', + label: '运行画面', + metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight }, + }, message.requestId); + } else if (message.type === 'host.slice.start') { + currentSlice = { sliceId: message.payload.sliceId, status: 'starting' }; + send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'starting' }, message.requestId); + dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message })); + if (currentSlice && currentSlice.sliceId === message.payload.sliceId && currentSlice.status === 'starting') { + currentSlice.status = 'playing'; + send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'playing' }); + } + return; + } else if (message.type === 'host.slice.stop') { + const sliceId = message.payload.sliceId; + if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = 'paused'; + send('runtime.slice', { sliceId, title: '', status: 'paused' }, message.requestId); + currentSlice = null; + clearInspection(); + } else if (message.type === 'host.stop') { + state = 'stopped'; + send('runtime.state', { status: state }, message.requestId); + } + dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message })); + if (message.type === 'host.stop') { + session = null; + hostOrigin = null; + } + }); +})(); +"#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PreviewListenerAcceptDisposition { @@ -382,6 +550,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 +565,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 +588,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 +622,82 @@ 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, preview_identity, previous_preview) = + registry.set_running_with_identity(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, + preview_identity, + }) +} + 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 @@ -560,6 +815,21 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) ); } + if url_path.split('?').next() == Some(GAME_RUN_BRIDGE_SCRIPT_PATH) { + let content_length = GAME_RUN_BRIDGE_BOOTSTRAP.len(); + let body = if is_head { + Vec::new() + } else { + GAME_RUN_BRIDGE_BOOTSTRAP.as_bytes().to_vec() + }; + return http_response( + "200 OK", + "text/javascript; charset=utf-8", + &body, + content_length, + ); + } + let file_path = match resolve_preview_path(root, url_path) { Ok(path) => path, Err(_) => { @@ -574,11 +844,41 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) return http_response("404 Not Found", "text/plain", body, b"not found".len()); } }; + let body = if file_path.extension().and_then(|value| value.to_str()) == Some("html") { + inject_game_run_bridge_tag(body) + } else { + body + }; let content_length = body.len(); let body = if is_head { Vec::new() } else { body }; http_response("200 OK", content_type(&file_path), &body, content_length) } +fn inject_game_run_bridge_tag(body: Vec) -> Vec { + let html = match String::from_utf8(body) { + Ok(html) => html, + Err(error) => return error.into_bytes(), + }; + if html.contains("data-genarrative-game-bridge=") { + return html.into_bytes(); + } + let lowercase = html.to_ascii_lowercase(); + let insertion_index = lowercase + .find("') + .map(|offset| start + offset + 1) + }) + .or_else(|| lowercase.find("")) + .unwrap_or(html.len()); + let mut injected = String::with_capacity(html.len() + GAME_RUN_BRIDGE_SCRIPT_TAG.len()); + injected.push_str(&html[..insertion_index]); + injected.push_str(GAME_RUN_BRIDGE_SCRIPT_TAG); + injected.push_str(&html[insertion_index..]); + injected.into_bytes() +} + pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result { let path = url_path.split('?').next().unwrap_or("/"); let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?; 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 80216aa36..aa396aaf0 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 @@ -799,6 +799,12 @@ pub(crate) fn read_manifest(path: &Path) -> Result manifest.runnable_versions.len() + || existing + .runnable_versions + .iter() + .zip(&manifest.runnable_versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("可运行版本记录写入后不可修改、删除或重排".to_string()); + } } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index cf4c331fc..19dc1006e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -1,6 +1,8 @@ use super::*; use shared_contracts::game_creation_app::{ GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, + RunnableGameVersion, RunnableGameVersionCreatedReason, RunnableGameVersionValidation, + RUNNABLE_GAME_VERSION_SCHEMA_VERSION, }; fn unique_manifest_test_root(test_name: &str) -> PathBuf { @@ -62,6 +64,41 @@ fn version_fixture( } } +fn runnable_version_fixture( + project_id: &str, + version_id: &str, + parent_version_id: Option<&str>, + project_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_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_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::new(), + test_slices: Vec::new(), + resource_descriptors: Vec::new(), + resource_replacement: None, + 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-{project_revision}"), + report_path: format!( + ".agent/runtime/browser-validations/program-agent/run-{project_revision}/1/validation.json" + ), + }, + created_at: project_revision, + } +} + #[test] fn manifest_versions_are_append_only_at_the_storage_boundary() { let root = unique_manifest_test_root("versions-append-only"); @@ -163,6 +200,73 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_ fs::remove_dir_all(root).ok(); } +#[test] +fn concurrent_manifest_write_cannot_overwrite_an_installed_runnable_version() { + let root = unique_manifest_test_root("runnable-versions-concurrent-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let project_id = "project-runnable-versioned"; + let mut stale_manifest = new_game_creation_app_manifest(project_id, "并发可运行版本项目"); + stale_manifest + .runnable_versions + .push(runnable_version_fixture( + project_id, + "runnable-root", + None, + 1, + RunnableGameVersionCreatedReason::Initial, + )); + stale_manifest.current_runnable_version_id = Some("runnable-root".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write initial runnable version"); + + let mut newer_manifest = stale_manifest.clone(); + newer_manifest + .runnable_versions + .push(runnable_version_fixture( + project_id, + "runnable-child", + Some("runnable-root"), + 2, + RunnableGameVersionCreatedReason::AgentRevision, + )); + newer_manifest.current_runnable_version_id = Some("runnable-child".to_string()); + let (newer_locked_tx, newer_locked_rx) = mpsc::channel(); + let (release_newer_tx, release_newer_rx) = mpsc::channel(); + let newer_path = manifest_path.clone(); + let newer_writer = std::thread::spawn(move || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + newer_locked_tx + .send(()) + .expect("signal newer lock acquired"); + release_newer_rx.recv().expect("release newer writer"); + }) + }); + newer_locked_rx + .recv_timeout(Duration::from_secs(2)) + .expect("newer writer acquires manifest lock"); + + let stale_path = manifest_path.clone(); + let stale_writer = std::thread::spawn(move || write_manifest(&stale_path, &stale_manifest)); + release_newer_tx.send(()).expect("release newer writer"); + + newer_writer + .join() + .expect("join newer writer") + .expect("install newer runnable manifest"); + let stale_error = stale_writer + .join() + .expect("join stale writer") + .expect_err("reject stale runnable manifest"); + assert!( + stale_error.contains("可运行版本记录写入后不可修改、删除或重排"), + "{stale_error}" + ); + let installed = read_manifest(&manifest_path).expect("read final manifest"); + assert_eq!(installed.runnable_versions.len(), 2); + assert_eq!(installed.runnable_versions[1].version_id, "runnable-child"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); 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..6223e1dce --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs @@ -0,0 +1,1818 @@ +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; +const TUNABLE_PARAMETER_REGISTRY_SCHEMA_VERSION: &str = "game-creator-tunable-parameters.v1"; +const TUNABLE_PARAMETER_VALUES_SCHEMA_VERSION: &str = "game-creator-tunable-values.v1"; +const TUNABLE_PARAMETER_REGISTRY_PATH: &str = "game/tunable-parameters.json"; +const TUNABLE_PARAMETER_VALUES_PATH: &str = "game/tunable-values.json"; +const TUNABLE_PARAMETER_MAX_COUNT: usize = 128; +const TUNABLE_PARAMETER_FILE_MAX_BYTES: u64 = 256 * 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, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TunableParameterRegistryFile { + schema_version: String, + parameters: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TunableParameterValuesFile { + schema_version: String, + values: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTunableParametersReadModel { + pub(crate) version_id: String, + pub(crate) project_revision: u64, + pub(crate) parameters: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateGameTunableParameterResult { + pub(crate) revision: u64, + pub(crate) parameters: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReplaceRunnableGameResourceResult { + pub(crate) manifest: GameCreationAppManifest, + pub(crate) version: RunnableGameVersion, +} + +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 runnable_test_slices(version_id: &str, scenario: BrowserPlaytestScenario) -> Vec { + let definitions: &[(&str, &str, &str, &str)] = match scenario { + BrowserPlaytestScenario::GenericV1 | BrowserPlaytestScenario::TetrisV1 => &[(( + "core-gameplay", + "核心玩法", + "游戏进入 ready", + "游戏进入 won 或 lost", + ))], + BrowserPlaytestScenario::LaneDefenseV1 => &[ + ( + "deploy-defender", + "部署防御单位", + "关卡进入 ready", + "防御单位完成部署", + ), + ( + "resolve-wave", + "推进当前波次", + "防御单位已部署", + "当前波次进入 won 或 lost", + ), + ( + "advance-level", + "进入下一关", + "当前波次已完成", + "下一关恢复 ready", + ), + ], + }; + definitions + .iter() + .enumerate() + .map( + |(order, (slice_id, title, start_condition, end_condition))| GameTestSlice { + slice_id: (*slice_id).to_string(), + version_id: version_id.to_string(), + title: (*title).to_string(), + order: u32::try_from(order).unwrap_or(u32::MAX), + start_condition: (*start_condition).to_string(), + end_condition: (*end_condition).to_string(), + status: GameTestSliceStatus::Idle, + }, + ) + .collect() +} + +fn runnable_resource_category( + media_type: &str, + kind: &str, + local_path: &str, +) -> Option { + let media_type = media_type.to_ascii_lowercase(); + let kind = kind.to_ascii_lowercase(); + let local_path = local_path.to_ascii_lowercase(); + if media_type.starts_with("audio/") + || [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac", ".opus"] + .iter() + .any(|extension| local_path.ends_with(extension)) + || ["audio", "bgm", "music", "sfx", "sound", "voice"] + .iter() + .any(|token| kind.contains(token)) + { + return Some(GameResourceCategory::Audio); + } + if media_type.starts_with("image/") + || media_type.starts_with("video/") + || [ + ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".mp4", ".webm", + ".mov", + ] + .iter() + .any(|extension| local_path.ends_with(extension)) + || [ + "art", + "animation", + "character", + "icon", + "image", + "scene", + "sprite", + "ui", + "video", + ] + .iter() + .any(|token| kind.contains(token)) + { + return Some(GameResourceCategory::Art); + } + if media_type.starts_with("text/") + || media_type.contains("json") + || media_type.contains("yaml") + || [ + ".md", + ".markdown", + ".txt", + ".json", + ".yaml", + ".yml", + ".toml", + ] + .iter() + .any(|extension| local_path.ends_with(extension)) + { + return Some(GameResourceCategory::Document); + } + None +} + +fn runnable_resource_descriptors( + root: &Path, + manifest: &GameCreationAppManifest, +) -> Vec { + manifest + .assets + .iter() + .filter_map(|asset| runnable_resource_descriptor(root, asset)) + .collect() +} + +fn runnable_resource_descriptor( + root: &Path, + asset: &GameCreationAppAssetManifestEntry, +) -> Option { + let category = runnable_resource_category(&asset.media_type, &asset.kind, &asset.local_path)?; + let bytes = normalize_relative_path(&asset.local_path) + .ok() + .and_then(|relative| resolve_local_project_path(root, &relative).ok()) + .and_then(|path| { + let metadata = fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES { + return None; + } + fs::read(path).ok() + }); + let (width, height) = if asset.media_type.starts_with("image/") { + bytes + .as_deref() + .and_then(|bytes| { + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .into_dimensions() + .ok() + }) + .map_or((None, None), |(width, height)| (Some(width), Some(height))) + } else { + (None, None) + }; + let audio_spec = if category == GameResourceCategory::Audio { + bytes.as_deref().and_then(parse_wav_size_spec) + } else { + None + }; + let name = Path::new(&asset.local_path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(asset.id.as_str()) + .to_string(); + Some(GameResourceDescriptor { + resource_id: asset.id.clone(), + name, + category, + subtype: asset.kind.clone(), + width, + height, + duration_ms: audio_spec.as_ref().map(|spec| spec.duration_ms), + channel_count: audio_spec.as_ref().map(|spec| spec.channel_count), + sample_rate_hz: audio_spec.as_ref().map(|spec| spec.sample_rate_hz), + codec: audio_spec.map(|spec| spec.codec), + format: asset.media_type.clone(), + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WavSizeSpec { + channel_count: u16, + sample_rate_hz: u32, + duration_ms: u64, + codec: String, +} + +fn parse_wav_size_spec(bytes: &[u8]) -> Option { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let mut offset = 12usize; + let mut format = None; + let mut data_bytes = None; + while offset.checked_add(8)? <= bytes.len() { + let chunk_id = &bytes[offset..offset + 4]; + let chunk_len = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().ok()?) as usize; + let content_start = offset.checked_add(8)?; + let content_end = content_start.checked_add(chunk_len)?; + if content_end > bytes.len() { + return None; + } + if chunk_id == b"fmt " && chunk_len >= 16 { + let audio_format = + u16::from_le_bytes(bytes[content_start..content_start + 2].try_into().ok()?); + let channel_count = u16::from_le_bytes( + bytes[content_start + 2..content_start + 4] + .try_into() + .ok()?, + ); + let sample_rate_hz = u32::from_le_bytes( + bytes[content_start + 4..content_start + 8] + .try_into() + .ok()?, + ); + let byte_rate = u32::from_le_bytes( + bytes[content_start + 8..content_start + 12] + .try_into() + .ok()?, + ); + if channel_count == 0 || sample_rate_hz == 0 || byte_rate == 0 { + return None; + } + let codec = match audio_format { + 1 => "pcm".to_string(), + 3 => "ieee-float".to_string(), + value => format!("wav-format-{value}"), + }; + format = Some((channel_count, sample_rate_hz, byte_rate, codec)); + } else if chunk_id == b"data" { + data_bytes = Some(chunk_len as u64); + } + offset = content_end.checked_add(chunk_len % 2)?; + } + let (channel_count, sample_rate_hz, byte_rate, codec) = format?; + let duration_ms = data_bytes?.checked_mul(1_000)? / u64::from(byte_rate); + Some(WavSizeSpec { + channel_count, + sample_rate_hz, + duration_ms, + codec, + }) +} + +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)?; + write_runnable_artifact_snapshot_from_files( + root, project_id, version_id, revision, created_at, files, + ) +} + +fn write_runnable_artifact_snapshot_from_files( + root: &Path, + project_id: &str, + version_id: &str, + revision: u64, + created_at: u64, + files: Vec<(String, Vec)>, +) -> Result { + 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, + playtest_scenario: BrowserPlaytestScenario, +) -> 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()); + } + if root.join(TUNABLE_PARAMETER_REGISTRY_PATH).exists() + || root.join(TUNABLE_PARAMETER_VALUES_PATH).exists() + { + let mut definitions = read_tunable_parameter_registry(root)?; + let values = read_editable_tunable_values(root)?; + overlay_editable_tunable_values(&mut definitions, values.as_ref())?; + } + 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 test_slices = runnable_test_slices(&version_id, playtest_scenario); + let resource_descriptors = runnable_resource_descriptors(root, &manifest); + 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, + test_slices, + resource_descriptors, + resource_replacement: None, + 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 compatible_resource_size_spec( + previous: &GameResourceDescriptor, + replacement: &GameResourceDescriptor, +) -> bool { + if previous.format != replacement.format { + return false; + } + match previous.category { + GameResourceCategory::Art if previous.format.starts_with("image/") => { + previous.width.is_some() + && previous.height.is_some() + && previous.width == replacement.width + && previous.height == replacement.height + } + GameResourceCategory::Art => { + previous.width.is_some() + && previous.height.is_some() + && previous.codec.is_some() + && previous.width == replacement.width + && previous.height == replacement.height + && previous.codec == replacement.codec + } + GameResourceCategory::Audio => { + previous.duration_ms.is_some() + && previous.channel_count.is_some() + && previous.sample_rate_hz.is_some() + && previous.codec.is_some() + && previous.duration_ms == replacement.duration_ms + && previous.channel_count == replacement.channel_count + && previous.sample_rate_hz == replacement.sample_rate_hz + && previous.codec == replacement.codec + } + GameResourceCategory::Document | GameResourceCategory::Version => false, + } +} + +fn read_manifest_asset_bytes( + root: &Path, + asset: &GameCreationAppAssetManifestEntry, +) -> Result, String> { + let relative = + normalize_relative_path(&asset.local_path).map_err(|_| "候选资源路径无效".to_string())?; + let path = resolve_local_project_path(root, &relative)?; + let metadata = fs::symlink_metadata(&path).map_err(|_| "候选资源不存在".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("候选资源必须是项目内普通文件".to_string()); + } + if metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES { + return Err("候选资源文件大小超过限制".to_string()); + } + read_runnable_source_file(&path, metadata.len()) + .map_err(|_| "候选资源读取期间发生变化".to_string()) +} + +pub(crate) fn replace_runnable_game_resource_at( + root: &Path, + expected_project_id: &str, + parent_version_id: &str, + slot_id: &str, + replacement_resource_id: &str, + expected_project_revision: u64, +) -> Result { + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path)?; + if manifest.project_id != expected_project_id { + return Err("资源替换项目身份不一致".to_string()); + } + if manifest.current_runnable_version_id.as_deref() != Some(parent_version_id) { + return Err("只能从当前可运行版本发起资源替换".to_string()); + } + let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + if current_revision != expected_project_revision { + return Err("项目 revision 已变化,请重新选择资源后再试".to_string()); + } + let parent = manifest + .runnable_versions + .iter() + .find(|version| version.version_id == parent_version_id) + .cloned() + .ok_or_else(|| "资源替换父版本不存在".to_string())?; + let parent_artifact = validate_runnable_game_version_artifact_at(root, &parent)?; + let previous_binding = parent + .resource_bindings + .iter() + .find(|binding| binding.slot_id == slot_id) + .cloned() + .ok_or_else(|| "当前版本不存在该资源绑定槽位".to_string())?; + if previous_binding.resource_id == replacement_resource_id { + return Err("候选资源与当前资源相同".to_string()); + } + let previous_asset = manifest + .assets + .iter() + .find(|asset| asset.id == previous_binding.resource_id) + .cloned() + .ok_or_else(|| "替换前资源已不在当前项目清单中".to_string())?; + let replacement_asset = manifest + .assets + .iter() + .find(|asset| asset.id == replacement_resource_id) + .cloned() + .ok_or_else(|| "候选资源未登记在当前项目清单中".to_string())?; + let previous_descriptor = parent + .resource_descriptors + .iter() + .find(|descriptor| descriptor.resource_id == previous_binding.resource_id) + .cloned() + .ok_or_else(|| "当前版本缺少替换前资源的可信元数据".to_string())?; + let replacement_descriptor = runnable_resource_descriptor(root, &replacement_asset) + .ok_or_else(|| "候选资源类型不支持正式替换".to_string())?; + if !matches!( + previous_descriptor.category, + GameResourceCategory::Art | GameResourceCategory::Audio + ) { + return Err("当前仅支持替换美术或音乐音效资源".to_string()); + } + if previous_descriptor.category != replacement_descriptor.category + || previous_descriptor.subtype != replacement_descriptor.subtype + || !compatible_resource_size_spec(&previous_descriptor, &replacement_descriptor) + { + return Err("资源类型不一致,无法替换(类别、subtype 或尺寸规格不匹配)".to_string()); + } + let previous_path = normalize_relative_path(&previous_asset.local_path) + .map_err(|_| "替换前资源路径无效".to_string())?; + if !previous_path.starts_with("assets/") { + return Err("替换前资源不属于可替换的 assets 产物".to_string()); + } + let replacement_bytes = read_manifest_asset_bytes(root, &replacement_asset)?; + let mut files = read_runnable_artifact_files(&parent_artifact)?; + let Some((_, previous_bytes)) = files.iter_mut().find(|(path, _)| path == &previous_path) + else { + return Err("父版本快照中不存在替换前资源文件".to_string()); + }; + *previous_bytes = replacement_bytes; + + let next_revision = advance_agent_runtime_project_revision_locked(root)?; + let version_id = runnable_version_id(next_revision); + if manifest + .runnable_versions + .iter() + .any(|version| version.version_id == version_id) + { + return Err("资源替换目标版本身份已存在".to_string()); + } + let created_at = unix_timestamp_millis(); + let snapshot = write_runnable_artifact_snapshot_from_files( + root, + &manifest.project_id, + &version_id, + next_revision, + created_at, + files, + )?; + let mut resource_bindings = parent.resource_bindings.clone(); + let binding = resource_bindings + .iter_mut() + .find(|binding| binding.slot_id == slot_id) + .ok_or_else(|| "资源替换槽位在提交前消失".to_string())?; + binding.resource_id = replacement_resource_id.to_string(); + let retained_resource_ids = resource_bindings + .iter() + .map(|binding| binding.resource_id.as_str()) + .collect::>(); + let mut resource_descriptors = parent + .resource_descriptors + .iter() + .filter(|descriptor| retained_resource_ids.contains(descriptor.resource_id.as_str())) + .cloned() + .collect::>(); + if !resource_descriptors + .iter() + .any(|descriptor| descriptor.resource_id == replacement_descriptor.resource_id) + { + resource_descriptors.push(replacement_descriptor); + } + resource_descriptors.sort_by(|left, right| left.resource_id.cmp(&right.resource_id)); + let test_slices = parent + .test_slices + .iter() + .cloned() + .map(|mut slice| { + slice.version_id = version_id.clone(); + slice.status = GameTestSliceStatus::Idle; + slice + }) + .collect(); + 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: Some(parent.version_id.clone()), + project_revision: next_revision, + artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"), + artifact_sha256: snapshot.artifact_sha256, + entry_path: "game/index.html".to_string(), + resource_bindings, + test_slices, + resource_descriptors, + resource_replacement: Some(RunnableGameResourceReplacement { + slot_id: slot_id.to_string(), + previous_resource_id: previous_binding.resource_id, + replacement_resource_id: replacement_resource_id.to_string(), + }), + created_reason: RunnableGameVersionCreatedReason::ResourceReplacement, + validation: parent.validation, + created_at: snapshot.created_at, + }; + manifest.runnable_versions.push(version.clone()); + write_manifest(&manifest_path, &manifest)?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.runnable_version.resource_replaced", + "versionId": version.version_id, + "parentVersionId": version.parent_version_id, + "projectRevision": version.project_revision, + "slotId": slot_id, + "previousResourceId": version.resource_replacement.as_ref().map(|value| &value.previous_resource_id), + "replacementResourceId": replacement_resource_id, + }), + ); + Ok(ReplaceRunnableGameResourceResult { manifest, version }) +} + +fn valid_tunable_parameter_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn validate_tunable_value( + definition: &GameTunableParameterDefinition, + value: &serde_json::Value, +) -> Result<(), String> { + match definition.value_type { + GameTunableParameterValueType::Boolean => { + if !value.is_boolean() { + return Err("参数值必须是布尔值".to_string()); + } + } + GameTunableParameterValueType::Enum => { + let candidate = value + .as_str() + .ok_or_else(|| "枚举参数值必须是字符串".to_string())?; + if !definition + .enum_values + .as_ref() + .is_some_and(|values| values.iter().any(|value| value == candidate)) + { + return Err("枚举参数值不在允许列表中".to_string()); + } + } + GameTunableParameterValueType::Integer | GameTunableParameterValueType::Number => { + let number = value + .as_f64() + .filter(|number| number.is_finite()) + .ok_or_else(|| "数值参数必须是有限数字".to_string())?; + if definition.value_type == GameTunableParameterValueType::Integer + && number.fract() != 0.0 + { + return Err("整数参数不能包含小数".to_string()); + } + if definition.min.is_some_and(|min| number < min) + || definition.max.is_some_and(|max| number > max) + { + return Err("参数值超出允许范围".to_string()); + } + if let Some(step) = definition.step { + let base = definition.min.unwrap_or(0.0); + let steps = (number - base) / step; + if (steps - steps.round()).abs() > 1e-8 { + return Err("参数值不符合步长约束".to_string()); + } + } + } + } + Ok(()) +} + +fn validate_tunable_definition(definition: &GameTunableParameterDefinition) -> Result<(), String> { + if !valid_tunable_parameter_id(&definition.parameter_id) + || definition.label.trim().is_empty() + || definition.label.len() > 128 + { + return Err("参数 ID 或显示名称无效".to_string()); + } + let expected_write_path = format!( + "{TUNABLE_PARAMETER_VALUES_PATH}#/values/{}", + definition.parameter_id + ); + if definition.write_path != expected_write_path + || definition.effect_mode != "next-relaunch" + || definition.editable_phase != "paused" + { + return Err("参数 writePath、生效方式或可编辑阶段不在白名单中".to_string()); + } + if definition.min.is_some_and(|value| !value.is_finite()) + || definition.max.is_some_and(|value| !value.is_finite()) + || definition + .step + .is_some_and(|value| !value.is_finite() || value <= 0.0) + || matches!((definition.min, definition.max), (Some(min), Some(max)) if min > max) + { + return Err("参数范围或步长无效".to_string()); + } + match definition.value_type { + GameTunableParameterValueType::Enum => { + let values = definition + .enum_values + .as_ref() + .filter(|values| !values.is_empty() && values.len() <= 64) + .ok_or_else(|| "枚举参数缺少允许值".to_string())?; + let mut unique = std::collections::HashSet::new(); + if values + .iter() + .any(|value| value.trim().is_empty() || value.len() > 128 || !unique.insert(value)) + { + return Err("枚举参数允许值无效或重复".to_string()); + } + } + _ if definition.enum_values.is_some() => { + return Err("非枚举参数不能声明 enumValues".to_string()); + } + _ => {} + } + validate_tunable_value(definition, &definition.default_value)?; + validate_tunable_value(definition, &definition.current_value) +} + +fn read_tunable_parameter_registry( + artifact_root: &Path, +) -> Result, String> { + let path = artifact_root.join(TUNABLE_PARAMETER_REGISTRY_PATH); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err("读取数值参数注册表失败".to_string()), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > TUNABLE_PARAMETER_FILE_MAX_BYTES + { + return Err("数值参数注册表必须是受限普通文件".to_string()); + } + let payload = fs::read_to_string(&path).map_err(|_| "读取数值参数注册表失败".to_string())?; + let registry = serde_json::from_str::(&payload) + .map_err(|_| "数值参数注册表格式无效".to_string())?; + if registry.schema_version != TUNABLE_PARAMETER_REGISTRY_SCHEMA_VERSION + || registry.parameters.len() > TUNABLE_PARAMETER_MAX_COUNT + { + return Err("数值参数注册表合同或数量无效".to_string()); + } + let mut ids = std::collections::HashSet::new(); + for definition in ®istry.parameters { + validate_tunable_definition(definition)?; + if !ids.insert(definition.parameter_id.as_str()) { + return Err("数值参数注册表包含重复参数 ID".to_string()); + } + } + Ok(registry.parameters) +} + +fn read_editable_tunable_values(root: &Path) -> Result, String> { + let path = resolve_local_project_path(root, TUNABLE_PARAMETER_VALUES_PATH)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("读取编辑态数值配置失败".to_string()), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > TUNABLE_PARAMETER_FILE_MAX_BYTES + { + return Err("编辑态数值配置必须是受限普通文件".to_string()); + } + let payload = fs::read_to_string(path).map_err(|_| "读取编辑态数值配置失败".to_string())?; + let values = serde_json::from_str::(&payload) + .map_err(|_| "编辑态数值配置格式无效".to_string())?; + if values.schema_version != TUNABLE_PARAMETER_VALUES_SCHEMA_VERSION { + return Err("编辑态数值配置合同无效".to_string()); + } + Ok(Some(values)) +} + +fn overlay_editable_tunable_values( + definitions: &mut [GameTunableParameterDefinition], + values: Option<&TunableParameterValuesFile>, +) -> Result<(), String> { + let Some(values) = values else { + return Ok(()); + }; + let registered = definitions + .iter() + .map(|definition| definition.parameter_id.as_str()) + .collect::>(); + if values + .values + .keys() + .any(|parameter_id| !registered.contains(parameter_id.as_str())) + { + return Err("编辑态数值配置包含未登记参数".to_string()); + } + for definition in definitions { + if let Some(value) = values.values.get(&definition.parameter_id) { + validate_tunable_value(definition, value)?; + definition.current_value = value.clone(); + } + } + Ok(()) +} + +pub(crate) fn read_game_tunable_parameters_at( + root: &Path, + expected_project_id: &str, + version_id: &str, +) -> Result { + let (_, version, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id, version_id)?; + let mut parameters = read_tunable_parameter_registry(&artifact_root)?; + let editable = read_editable_tunable_values(root)?; + overlay_editable_tunable_values(&mut parameters, editable.as_ref())?; + let project_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + Ok(GameTunableParametersReadModel { + version_id: version.version_id, + project_revision, + parameters, + }) +} + +fn write_tunable_values_atomically( + root: &Path, + values: &TunableParameterValuesFile, +) -> Result<(), String> { + let path = resolve_local_project_path(root, TUNABLE_PARAMETER_VALUES_PATH)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| "创建数值配置目录失败".to_string())?; + } + if fs::symlink_metadata(&path) + .is_ok_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err("编辑态数值配置目标不是普通文件".to_string()); + } + let payload = + serde_json::to_string_pretty(values).map_err(|_| "序列化编辑态数值配置失败".to_string())?; + if payload.len() > TUNABLE_PARAMETER_FILE_MAX_BYTES as usize { + return Err("编辑态数值配置超过大小限制".to_string()); + } + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temp = path.with_file_name(format!( + ".tunable-values.tmp.{}.{}", + std::process::id(), + nonce + )); + let backup = path.with_file_name(format!( + ".tunable-values.previous.{}.{}", + std::process::id(), + nonce + )); + fs::write(&temp, format!("{payload}\n")) + .map_err(|_| "写入编辑态数值配置临时文件失败".to_string())?; + if path.exists() { + fs::rename(&path, &backup).map_err(|_| { + let _ = fs::remove_file(&temp); + "准备替换编辑态数值配置失败".to_string() + })?; + } + if let Err(_) = fs::rename(&temp, &path) { + if backup.exists() { + let _ = fs::rename(&backup, &path); + } + let _ = fs::remove_file(&temp); + return Err("替换编辑态数值配置失败".to_string()); + } + if backup.exists() { + let _ = fs::remove_file(backup); + } + Ok(()) +} + +pub(crate) fn update_game_tunable_parameter_at( + root: &Path, + expected_project_id: &str, + version_id: &str, + parameter_id: &str, + value: serde_json::Value, + expected_project_revision: u64, +) -> Result { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + if current_revision != expected_project_revision { + return Err("项目 revision 已变化,请刷新数值面板后再试".to_string()); + } + let (_, _, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id, version_id)?; + let mut parameters = read_tunable_parameter_registry(&artifact_root)?; + let definition = parameters + .iter() + .find(|definition| definition.parameter_id == parameter_id) + .cloned() + .ok_or_else(|| "数值参数未登记,禁止新增写入路径".to_string())?; + validate_tunable_definition(&definition)?; + validate_tunable_value(&definition, &value)?; + let mut values = + read_editable_tunable_values(root)?.unwrap_or_else(|| TunableParameterValuesFile { + schema_version: TUNABLE_PARAMETER_VALUES_SCHEMA_VERSION.to_string(), + values: parameters + .iter() + .map(|definition| { + ( + definition.parameter_id.clone(), + definition.current_value.clone(), + ) + }) + .collect(), + }); + values.values.insert(parameter_id.to_string(), value); + overlay_editable_tunable_values(&mut parameters, Some(&values))?; + let revision = advance_agent_runtime_project_revision_locked(root)?; + write_tunable_values_atomically(root, &values)?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.tunable_parameter.updated", + "versionId": version_id, + "parameterId": parameter_id, + "projectRevision": revision, + }), + ); + Ok(UpdateGameTunableParameterResult { + revision, + parameters, + }) +} + +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 mut manifest = new_game_creation_app_manifest( + format!("project-{label}"), + format!("可运行版本测试 {label}"), + ); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "player-document".to_string(), + kind: "design-document".to_string(), + media_type: "text/plain".to_string(), + local_path: "assets/player.txt".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + 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" + ), + BrowserPlaytestScenario::GenericV1, + ) + .expect("register runnable version") + } + + fn set_test_project_revision(root: &Path, revision: u64) { + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + write_game_creator_agent_runtime_project_revision( + root, + &AgentRuntimeProjectRevision { + schema_version: AGENT_RUNTIME_PROJECT_REVISION_SCHEMA_VERSION.to_string(), + project_id: manifest.project_id, + revision, + updated_at: unix_timestamp(), + }, + ) + .expect("write project revision"); + } + + fn add_test_png_asset(root: &Path, id: &str, file_name: &str, width: u32, height: u32) { + let path = root.join("assets").join(file_name); + image::RgbaImage::new(width, height) + .save(&path) + .expect("write png asset"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "character".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{file_name}"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("write png asset manifest"); + } + + fn add_test_wav_asset( + root: &Path, + id: &str, + file_name: &str, + channel_count: u16, + sample_rate_hz: u32, + duration_ms: u32, + ) { + let bytes_per_sample = 2u16; + let block_align = channel_count * bytes_per_sample; + let byte_rate = sample_rate_hz * u32::from(block_align); + let data_len = byte_rate * duration_ms / 1_000; + let mut bytes = Vec::with_capacity(44 + data_len as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16u32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&channel_count.to_le_bytes()); + bytes.extend_from_slice(&sample_rate_hz.to_le_bytes()); + bytes.extend_from_slice(&byte_rate.to_le_bytes()); + bytes.extend_from_slice(&block_align.to_le_bytes()); + bytes.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + bytes.resize(44 + data_len as usize, 0); + fs::write(root.join("assets").join(file_name), bytes).expect("write wav asset"); + + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "sound-effect".to_string(), + media_type: "audio/wav".to_string(), + local_path: format!("assets/{file_name}"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("write wav asset manifest"); + } + + #[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!( + first + .test_slices + .iter() + .map(|slice| slice.slice_id.as_str()) + .collect::>(), + vec!["core-gameplay"] + ); + assert_eq!(first.test_slices[0].version_id, first.version_id); + assert_eq!(first.test_slices[0].status, GameTestSliceStatus::Idle); + assert_eq!( + first.resource_descriptors, + vec![GameResourceDescriptor { + resource_id: "player-document".to_string(), + name: "player.txt".to_string(), + category: GameResourceCategory::Document, + subtype: "design-document".to_string(), + width: None, + height: None, + duration_ms: None, + channel_count: None, + sample_rate_hz: None, + codec: None, + format: "text/plain".to_string(), + }] + ); + 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_test_slices_follow_the_verified_playtest_scenario() { + let generic = runnable_test_slices("runnable-r1", BrowserPlaytestScenario::GenericV1); + assert_eq!( + generic + .iter() + .map(|slice| slice.slice_id.as_str()) + .collect::>(), + vec!["core-gameplay"] + ); + + let lane = runnable_test_slices("runnable-r2", BrowserPlaytestScenario::LaneDefenseV1); + assert_eq!( + lane.iter() + .map(|slice| (slice.order, slice.slice_id.as_str())) + .collect::>(), + vec![ + (0, "deploy-defender"), + (1, "resolve-wave"), + (2, "advance-level"), + ] + ); + assert!(lane.iter().all(|slice| { + slice.version_id == "runnable-r2" && slice.status == GameTestSliceStatus::Idle + })); + + let tetris = runnable_test_slices("runnable-r3", BrowserPlaytestScenario::TetrisV1); + assert_eq!( + tetris + .iter() + .map(|slice| slice.slice_id.as_str()) + .collect::>(), + vec!["core-gameplay"] + ); + } + + #[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(); + } + + #[test] + fn resource_replacement_creates_a_child_snapshot_without_switching_the_current_version() { + let root = init_runnable_version_test_project("resource-replacement"); + add_test_png_asset(&root, "hero-a", "hero-a.png", 2, 2); + add_test_png_asset(&root, "hero-b", "hero-b.png", 2, 2); + image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255])) + .save(root.join("assets/hero-a.png")) + .expect("make hero-a bytes distinct"); + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + let hero_a = manifest + .assets + .iter() + .find(|asset| asset.id == "hero-a") + .cloned() + .expect("hero-a asset"); + let descriptor = runnable_resource_descriptor(&root, &hero_a).expect("hero-a descriptor"); + image::RgbaImage::new(descriptor.width.unwrap(), descriptor.height.unwrap()) + .save(root.join("assets/hero-b.png")) + .expect("rewrite matching hero-b"); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("keep manifest"); + set_test_project_revision(&root, 1); + let parent = register_test_version(&root, 1); + let parent_bytes = + fs::read(root.join(".agent/runnable-versions/runnable-r1/artifact/assets/hero-a.png")) + .expect("read parent hero"); + let replacement_bytes = fs::read(root.join("assets/hero-b.png")).expect("read candidate"); + + let result = replace_runnable_game_resource_at( + &root, + "project-resource-replacement", + &parent.version_id, + "hero-a", + "hero-b", + 1, + ) + .expect("replace compatible resource"); + assert_eq!( + result.manifest.current_runnable_version_id.as_deref(), + Some(parent.version_id.as_str()) + ); + assert_eq!( + result.version.created_reason, + RunnableGameVersionCreatedReason::ResourceReplacement + ); + assert_eq!(result.version.parent_version_id, Some(parent.version_id)); + assert_eq!( + result.version.resource_replacement, + Some(RunnableGameResourceReplacement { + slot_id: "hero-a".to_string(), + previous_resource_id: "hero-a".to_string(), + replacement_resource_id: "hero-b".to_string(), + }) + ); + assert_eq!( + fs::read(root.join(".agent/runnable-versions/runnable-r2/artifact/assets/hero-a.png")) + .expect("read child hero"), + replacement_bytes + ); + assert_eq!( + fs::read(root.join(".agent/runnable-versions/runnable-r1/artifact/assets/hero-a.png")) + .expect("read unchanged parent hero"), + parent_bytes + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_replacement_rejects_incompatible_size_before_advancing_revision() { + let root = init_runnable_version_test_project("resource-size-mismatch"); + add_test_png_asset(&root, "hero-small", "hero-small.png", 2, 2); + add_test_png_asset(&root, "hero-large", "hero-large.png", 3, 3); + set_test_project_revision(&root, 1); + let parent = register_test_version(&root, 1); + let error = replace_runnable_game_resource_at( + &root, + "project-resource-size-mismatch", + &parent.version_id, + "hero-small", + "hero-large", + 1, + ) + .expect_err("reject incompatible size"); + assert!(error.contains("尺寸规格不匹配"), "{error}"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read stable revision") + .revision, + 1 + ); + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + assert_eq!(manifest.runnable_versions.len(), 1); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_replacement_uses_trusted_wav_channel_rate_and_duration_metadata() { + let root = init_runnable_version_test_project("resource-wav-spec"); + add_test_wav_asset(&root, "sfx-a", "sfx-a.wav", 1, 44_100, 100); + add_test_wav_asset(&root, "sfx-b", "sfx-b.wav", 1, 44_100, 100); + add_test_wav_asset(&root, "sfx-stereo", "sfx-stereo.wav", 2, 44_100, 100); + set_test_project_revision(&root, 1); + let parent = register_test_version(&root, 1); + let descriptor = parent + .resource_descriptors + .iter() + .find(|descriptor| descriptor.resource_id == "sfx-a") + .expect("registered wav descriptor"); + assert_eq!(descriptor.channel_count, Some(1)); + assert_eq!(descriptor.sample_rate_hz, Some(44_100)); + assert_eq!(descriptor.duration_ms, Some(100)); + assert_eq!(descriptor.codec.as_deref(), Some("pcm")); + + replace_runnable_game_resource_at( + &root, + "project-resource-wav-spec", + &parent.version_id, + "sfx-a", + "sfx-b", + 1, + ) + .expect("replace compatible wav resource"); + let error = replace_runnable_game_resource_at( + &root, + "project-resource-wav-spec", + &parent.version_id, + "sfx-a", + "sfx-stereo", + 2, + ) + .expect_err("reject wav with a different channel count"); + assert!(error.contains("尺寸规格不匹配"), "{error}"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read stable revision") + .revision, + 2 + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn tunable_parameter_update_is_registry_bound_and_advances_only_editable_revision() { + let root = init_runnable_version_test_project("tunable-update"); + fs::write( + root.join(TUNABLE_PARAMETER_REGISTRY_PATH), + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": TUNABLE_PARAMETER_REGISTRY_SCHEMA_VERSION, + "parameters": [{ + "parameterId": "player.speed", + "label": "移动速度", + "valueType": "number", + "defaultValue": 1.0, + "currentValue": 1.0, + "min": 0.5, + "max": 2.0, + "step": 0.1, + "unit": "倍", + "writePath": "game/tunable-values.json#/values/player.speed", + "effectMode": "next-relaunch", + "editablePhase": "paused", + "codeMutationAllowed": false + }] + })) + .expect("serialize tunable registry"), + ) + .expect("write tunable registry"); + set_test_project_revision(&root, 1); + let version = register_test_version(&root, 1); + let initial = + read_game_tunable_parameters_at(&root, "project-tunable-update", &version.version_id) + .expect("read tunable registry"); + assert_eq!(initial.parameters[0].current_value, serde_json::json!(1.0)); + + let updated = update_game_tunable_parameter_at( + &root, + "project-tunable-update", + &version.version_id, + "player.speed", + serde_json::json!(1.5), + 1, + ) + .expect("update registered parameter"); + assert_eq!(updated.revision, 2); + assert_eq!(updated.parameters[0].current_value, serde_json::json!(1.5)); + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + assert_eq!(manifest.runnable_versions, vec![version.clone()]); + assert_eq!( + read_game_tunable_parameters_at(&root, "project-tunable-update", &version.version_id,) + .expect("read editable tunable value") + .parameters[0] + .current_value, + serde_json::json!(1.5) + ); + + let error = update_game_tunable_parameter_at( + &root, + "project-tunable-update", + &version.version_id, + "unregistered.path", + serde_json::json!(1), + 2, + ) + .expect_err("reject unregistered parameter"); + assert!(error.contains("未登记"), "{error}"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read stable revision") + .revision, + 2 + ); + 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 b303136d4..a2c5618fa 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 @@ -2433,6 +2433,41 @@ fn preview_registry_reports_status_and_stops_previous_server() { assert_eq!(registry.status().status, "stopped"); } +#[test] +fn preview_registry_rotates_p3_identity_with_the_owned_server() { + let registry = PreviewRegistry::default(); + let (first_stop, first_receiver) = mpsc::channel(); + let (_, first_identity, previous) = registry.set_running_with_identity( + LocalPreviewResult { + url: "http://127.0.0.1:4101/".to_string(), + port: 4101, + root: "/tmp/game-one".to_string(), + }, + first_stop, + ); + assert!(previous.is_none()); + assert_eq!(first_identity.origin, "http://127.0.0.1:4101"); + assert!(first_identity.preview_id.starts_with("preview-")); + + let (second_stop, _) = mpsc::channel(); + let (_, second_identity, previous) = registry.set_running_with_identity( + LocalPreviewResult { + url: "http://127.0.0.1:4102/".to_string(), + port: 4102, + root: "/tmp/game-two".to_string(), + }, + second_stop, + ); + assert!(first_receiver.try_recv().is_ok()); + assert_eq!( + previous.expect("replaced preview").url, + "http://127.0.0.1:4101/" + ); + assert_ne!(first_identity.preview_id, second_identity.preview_id); + assert_eq!(second_identity.origin, "http://127.0.0.1:4102"); + registry.stop(); +} + #[test] fn replaced_preview_records_stopped_state() { let root = unique_project_path(); @@ -3240,6 +3275,74 @@ 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_injects_the_host_owned_runtime_bridge_without_mutating_html() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-bridge", "运行桥预览测试").expect("project init"); + let original = "Bridge"; + fs::write(root.join("game/index.html"), original).expect("write bridge fixture"); + + let html_response = + String::from_utf8(build_preview_response(&root, "GET", "/")).expect("html response"); + assert!(html_response.contains("data-genarrative-game-bridge=\"v1\"")); + assert!(html_response.contains("/.genarrative/game-bridge.v1.js")); + assert_eq!( + fs::read_to_string(root.join("game/index.html")).expect("read original html"), + original + ); + + let script_response = String::from_utf8(build_preview_response( + &root, + "GET", + "/.genarrative/game-bridge.v1.js", + )) + .expect("bridge response"); + assert!(script_response.contains("Content-Type: text/javascript; charset=utf-8")); + assert!(script_response.contains("game-creator-run-bridge.v1")); + assert!(script_response.contains("runtime.ready")); + assert!(script_response.contains("event.source !== parent")); + assert!(script_response.contains("host.slice.start")); + assert!(script_response.contains("host.slice.stop")); + assert!(script_response.contains("reportTargetInspection")); + assert!(script_response.contains("clearInspection")); + assert!(script_response.contains("Object.keys(message.payload).length !== 1")); + + 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-tauri/tauri.app-run-dev.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json new file mode 100644 index 000000000..5471b636c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Genarrative AI Game Creator App Run", + "identifier": "world.genarrative.ai-game-creator.app-run", + "build": { + "devUrl": "http://127.0.0.1:3081/" + }, + "app": { + "windows": [ + { + "label": "client", + "title": "AI 游戏创作 · App Run", + "url": "index.html", + "width": 1280, + "height": 800, + "minWidth": 1280, + "minHeight": 800 + } + ] + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index fc097ae68..e4d1d79a3 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -831,9 +831,7 @@ export function App({ projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; const refreshManifest = useCallback( - ( - nextProjectPath = localProjectPathRef.current ?? '', - ): Promise => { + (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { return Promise.resolve(); 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 7cd31249c..5fb3d4f85 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 @@ -52,6 +52,7 @@ export function WorkspaceLauncherShell({ setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, + updateCurrentProjectManifest, resetLauncherHomeDraft, createHomeDraft, openProject, @@ -176,6 +177,8 @@ export function WorkspaceLauncherShell({ agentResults={activeProjectAgentResults} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} + onManifestChange={updateCurrentProjectManifest} + onPreviewChange={setActiveProjectPreview} supervisor={ { + setCurrentProjectContext((current) => + current && current.manifest.projectId === manifest.projectId + ? current.manifest === manifest + ? current + : { ...current, manifest } + : current, + ); + }, []); + async function importHomeAttachments( invoke: TauriInvoke, nextProjectPath: string, @@ -437,6 +448,7 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + updateCurrentProjectManifest, pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 401757601..5d498de8f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,10 +1,54 @@ -/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ +/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its rendering adapter for focused tests. */ + +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; + +import type { + GameHostMessageType, + GameRunSession, + GameRuntimeMessage, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameHostMessage, + createGameRunSession, + type GameRunBridgeIdentity, + GameRunBridgeRuntimeGuard, + gameRunSessionFromRuntimeMessage, + newGameRunBridgeRequestId, + newGameRunSessionId, + validateGameRunBridgeIdentity, +} from './gameRunBridge'; export type LocalGamePreviewLike = { status?: string | null; url?: string | null; }; +export type LocalGamePreviewFrameHandle = { + pause: () => boolean; + resume: () => boolean; + stop: () => boolean; + requestState: () => boolean; + requestInspection: () => boolean; + startSlice: (sliceId: string) => boolean; + stopSlice: (sliceId: string) => boolean; + markStale: ( + reason: 'project-revision-changed' | 'version-changed', + ) => boolean; +}; + +type ActiveBridgeSession = { + guard: GameRunBridgeRuntimeGuard; + identity: GameRunBridgeIdentity; + nextHostSequence: number; + session: GameRunSession; +}; + export function resolveEmbeddedPreviewUrl( preview: LocalGamePreviewLike | null | undefined, ) { @@ -25,26 +69,189 @@ export function resolveEmbeddedPreviewUrl( } } -export function LocalGamePreviewFrame({ - preview, - title, - className, -}: { - preview: LocalGamePreviewLike | null | undefined; - title: string; - className?: string; -}) { +export const LocalGamePreviewFrame = forwardRef< + LocalGamePreviewFrameHandle, + { + preview: LocalGamePreviewLike | null | undefined; + title: string; + className?: string; + runIdentity?: GameRunBridgeIdentity | null; + onSessionChange?: (session: GameRunSession) => void; + onRuntimeMessage?: (message: GameRuntimeMessage) => void; + onBridgeError?: (message: string) => void; + } +>(function LocalGamePreviewFrame( + { + preview, + title, + className, + runIdentity, + onSessionChange, + onRuntimeMessage, + onBridgeError, + }, + forwardedRef, +) { const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + const iframeRef = useRef(null); + const activeRef = useRef(null); + const callbacksRef = useRef({ + onBridgeError, + onRuntimeMessage, + onSessionChange, + }); + callbacksRef.current = { + onBridgeError, + onRuntimeMessage, + onSessionChange, + }; + + const sendHostMessage = useCallback( + (type: GameHostMessageType, sliceId?: string) => { + const active = activeRef.current; + const targetWindow = iframeRef.current?.contentWindow; + if (!active || !targetWindow) { + return false; + } + const requestId = newGameRunBridgeRequestId('host'); + if (!requestId || !active.guard.registerHostRequest(requestId, type)) { + callbacksRef.current.onBridgeError?.('运行会话无法创建安全请求身份'); + return false; + } + const message = createGameHostMessage({ + identity: active.identity, + sessionId: active.session.sessionId, + requestId, + sequence: active.nextHostSequence, + type, + payload: sliceId ? { sliceId } : undefined, + }); + active.nextHostSequence += 1; + targetWindow.postMessage(message, active.identity.previewOrigin); + return true; + }, + [], + ); + + const stopActiveSession = useCallback( + (notify: boolean) => { + const active = activeRef.current; + if (!active) { + return false; + } + sendHostMessage('host.stop'); + activeRef.current = null; + if (notify) { + callbacksRef.current.onSessionChange?.({ + ...active.session, + status: 'stopped', + updatedAt: Date.now(), + }); + } + return true; + }, + [sendHostMessage], + ); + + useImperativeHandle( + forwardedRef, + () => ({ + pause: () => sendHostMessage('host.pause'), + resume: () => sendHostMessage('host.resume'), + stop: () => stopActiveSession(true), + requestState: () => sendHostMessage('host.state.request'), + requestInspection: () => sendHostMessage('host.inspection.request'), + startSlice: (sliceId) => sendHostMessage('host.slice.start', sliceId), + stopSlice: (sliceId) => sendHostMessage('host.slice.stop', sliceId), + markStale: (reason) => { + const active = activeRef.current; + if (!active) { + return false; + } + active.session = { + ...active.session, + stale: true, + staleReason: reason, + updatedAt: Date.now(), + }; + callbacksRef.current.onSessionChange?.(active.session); + return true; + }, + }), + [sendHostMessage, stopActiveSession], + ); + + useEffect(() => { + function receiveRuntimeMessage(event: MessageEvent) { + const active = activeRef.current; + if ( + !active || + event.source !== iframeRef.current?.contentWindow || + event.origin !== active.identity.previewOrigin + ) { + return; + } + const message = active.guard.accept(event.data); + if (!message) { + return; + } + active.session = gameRunSessionFromRuntimeMessage( + active.session, + message, + Date.now(), + ); + callbacksRef.current.onSessionChange?.(active.session); + callbacksRef.current.onRuntimeMessage?.(message); + } + window.addEventListener('message', receiveRuntimeMessage); + return () => { + window.removeEventListener('message', receiveRuntimeMessage); + stopActiveSession(false); + }; + }, [stopActiveSession]); + + function handleLoad() { + stopActiveSession(true); + if (!runIdentity || !embeddedUrl) { + return; + } + const identity = validateGameRunBridgeIdentity(runIdentity); + if (!identity || new URL(embeddedUrl).origin !== identity.previewOrigin) { + callbacksRef.current.onBridgeError?.( + '运行预览身份与 loopback origin 不一致', + ); + return; + } + const sessionId = newGameRunSessionId(); + if (!sessionId) { + callbacksRef.current.onBridgeError?.('当前 WebView 不支持安全运行会话'); + return; + } + const session = createGameRunSession(identity, sessionId, Date.now()); + activeRef.current = { + guard: new GameRunBridgeRuntimeGuard(identity, sessionId), + identity, + nextHostSequence: 1, + session, + }; + callbacksRef.current.onSessionChange?.(session); + if (!sendHostMessage('host.start')) { + activeRef.current = null; + } + } + if (!embeddedUrl) { return null; } return (