From 602723ea0df568bca879249c089957d2b8c7c506 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 3 Aug 2026 23:02:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=B8=E6=88=8F=E7=BB=AD?= =?UTF-8?q?=E8=B7=91=E4=B8=8E=E5=9B=BE=E9=9B=86=E5=AE=8C=E6=88=90=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保持失败续跑的原始任务身份并向 Provider 传递有效目标 收紧俄罗斯方块玩法连续性和浏览器动作因果验证 将图集主图、四切片、清单与登记纳入可恢复原子提交 校验切片来源、规范像素唯一性、可见性与有界解码 保留 External 生成结果的稳定来源资源字段 补齐调度子 Agent 展示和事件流测试 同步 Runtime 技术方案与项目决策记录 --- .../src-tauri/src/agent/generation.rs | 1 + .../src/agent/generation/canvas_generation.rs | 943 +++++++++++++++--- .../generation/external_generation_state.rs | 99 +- .../provider_request_builders.rs | 8 +- .../src/agent/runtime_driver/entrypoints.rs | 38 +- .../runtime_driver/game_chat_fast_path.rs | 148 ++- .../agent/runtime_driver/main_loop_tests.rs | 6 + .../runtime_protocol/autonomous_completion.rs | 496 ++++++++- .../autonomous_completion_contract_tests.rs | 243 +++++ .../agent/runtime_protocol/context_bundle.rs | 19 +- .../src/agent/runtime_tools/delivery.rs | 9 +- .../src/agent/runtime_tools/media.rs | 32 +- .../src-tauri/src/assets.rs | 325 +++++- .../src-tauri/src/browser/playtest/generic.rs | 154 +++ .../src-tauri/src/browser/playtest/mod.rs | 10 +- .../src-tauri/src/browser/tests.rs | 2 +- .../src/features/agent-runtime/model.ts | 6 +- .../appSurface/project-development.suite.ts | 1 + .../shared-memory/decision-log.md | 4 +- .../shared-memory/development-workflow.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- .../src/external_generation_worker.rs | 22 +- 22 files changed, 2337 insertions(+), 235 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e896921fb..49cd08903 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -17,6 +17,7 @@ pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, platform_art_generation_error_needs_reconciliation, request_platform_art_asset_with_runtime_options_at, + validate_platform_art_png_bytes_with_limits, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; pub(in crate::agent) use external_generation_state::{ 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 718bf8f31..1e19e8457 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 @@ -19,6 +19,11 @@ const EXTERNAL_GENERATION_MAX_POLL_AFTER_MS: u64 = 5_000; const EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-result-unknown:"; const EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX: &str = "platform-generation-source-preserved-no-retry:"; +const PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES: usize = 32 * 1024 * 1024; +const PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES: usize = 20 * 1024 * 1024; +const PLATFORM_ART_SPRITESHEET_MAX_DIMENSION: u32 = 4_096; +const PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS: u64 = 16 * 1024 * 1024; +const PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC: u64 = 64 * 1024 * 1024; pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { read_manifest_for_project(root) @@ -641,6 +646,12 @@ struct PreparedPlatformArtAssetSlice { download: CanvasResourceDownload, resource_id: Option, asset_object_id: Option, + canvas_project_id: Option, + task_id: Option, + source_resource_id: Option, + content_sha256: String, + pixel_sha256: String, + has_visible_pixels: bool, extension: String, } @@ -662,7 +673,10 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { generation_route: String, generation_kind: String, reference_resource_ids: Vec, + spritesheet_has_transparent_pixels: bool, + spritesheet_has_visible_pixels: bool, extension: String, + recover_existing_outputs: bool, } impl PreparedPlatformArtAssetGeneration { @@ -727,11 +741,87 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { .collect() } -fn platform_art_spritesheet_has_transparent_pixels(download: &CanvasResourceDownload) -> bool { - image::load_from_memory(&download.bytes) - .ok() - .map(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) - .unwrap_or(false) +fn decode_platform_art_image_with_limits( + download: &CanvasResourceDownload, + label: &str, +) -> Result { + decode_platform_art_image_bytes_with_limits(&download.bytes, label) +} + +fn decode_platform_art_image_bytes_with_limits( + bytes: &[u8], + label: &str, +) -> Result { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| format!("{label}无法识别图片格式:{error}"))?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); + limits.max_image_height = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); + limits.max_alloc = Some(PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC); + reader.limits(limits); + reader + .decode() + .map_err(|error| format!("{label}无法在安全内存边界内解码:{error}")) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) struct ValidatedPlatformArtPng { + pub(in crate::agent) width: u32, + pub(in crate::agent) height: u32, + pub(in crate::agent) content_sha256: String, + pub(in crate::agent) pixel_sha256: String, + pub(in crate::agent) has_visible_pixels: bool, +} + +pub(in crate::agent) fn validate_platform_art_png_bytes_with_limits( + bytes: &[u8], + label: &str, +) -> Result { + if bytes.len() > PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES { + return Err(format!("{label}超过 20 MiB 本地校验上限")); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| format!("{label}无法识别图片格式:{error}"))?; + if reader.format() != Some(image::ImageFormat::Png) { + return Err(format!("{label}不是 PNG 图片")); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); + limits.max_image_height = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); + limits.max_alloc = Some(PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC); + reader.limits(limits); + let decoded = reader + .decode() + .map_err(|error| format!("{label}无法在安全内存边界内解码:{error}"))?; + let width = decoded.width(); + let height = decoded.height(); + let rgba = decoded.into_rgba8(); + let mut pixel_digest = Sha256::new(); + pixel_digest.update(width.to_be_bytes()); + pixel_digest.update(height.to_be_bytes()); + pixel_digest.update(rgba.as_raw()); + Ok(ValidatedPlatformArtPng { + width, + height, + content_sha256: format!("{:x}", Sha256::digest(bytes)), + pixel_sha256: format!("{:x}", pixel_digest.finalize()), + has_visible_pixels: rgba.pixels().any(|pixel| pixel[3] > 0), + }) +} + +fn platform_art_spritesheet_alpha_contract( + download: &CanvasResourceDownload, +) -> Result<(bool, bool, u64), String> { + let image = decode_platform_art_image_with_limits(download, "平台 art-spritesheet")?; + let pixels = u64::from(image.width()) + .checked_mul(u64::from(image.height())) + .ok_or_else(|| "平台 art-spritesheet 像素数量溢出".to_string())?; + let rgba = image.to_rgba8(); + let has_transparent = rgba.pixels().any(|pixel| pixel[3] < u8::MAX); + let has_visible = rgba.pixels().any(|pixel| pixel[3] > 0); + Ok((has_transparent, has_visible, pixels)) } fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> Option { @@ -764,6 +854,8 @@ async fn prepare_platform_art_spritesheet_slices( api_base_url: &str, api_key: &str, generated: &serde_json::Value, + initial_download_bytes: usize, + initial_decoded_pixels: u64, ) -> Result, String> { let Some(icons) = generated .get("iconImageSrcs") @@ -775,41 +867,69 @@ async fn prepare_platform_art_spritesheet_slices( return Err("External Editor 返回的图集切片超过 64 个,已拒绝同步".to_string()); } let mut prepared = Vec::with_capacity(icons.len()); - let mut total_download_bytes = 0usize; + if initial_download_bytes > PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES + || initial_decoded_pixels > PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS + { + return Err("External Editor 返回的整图已耗尽图集本地处理预算".to_string()); + } + let mut total_download_bytes = initial_download_bytes; + let mut total_decoded_pixels = initial_decoded_pixels; for (index, icon) in icons.iter().enumerate() { let resource = icon .get("resource") .filter(|value| value.is_object()) .unwrap_or(icon); - let download = resolve_canvas_resource_download(client, api_base_url, api_key, resource) - .await? - .ok_or_else(|| format!("平台图集第 {} 个切片缺少可下载图片", index + 1))?; + let remaining_download_bytes = PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES + .checked_sub(total_download_bytes) + .ok_or_else(|| "平台图集切片累计大小溢出".to_string())?; + let download = resolve_canvas_resource_download_with_limit( + client, + api_base_url, + api_key, + resource, + remaining_download_bytes.min(PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES), + ) + .await? + .ok_or_else(|| format!("平台图集第 {} 个切片缺少可下载图片", index + 1))?; total_download_bytes = total_download_bytes .checked_add(download.bytes.len()) .ok_or_else(|| "平台图集切片累计大小溢出".to_string())?; - if total_download_bytes > 32 * 1024 * 1024 { + if total_download_bytes > PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES { return Err("External Editor 返回的图集切片累计超过 32 MiB,已拒绝同步".to_string()); } - let decoded = image::load_from_memory(&download.bytes) - .map_err(|error| format!("平台图集第 {} 个切片无法解码:{error}", index + 1))?; + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + &format!("平台图集第 {} 个切片", index + 1), + )?; + let decoded_pixels = u64::from(validated.width) + .checked_mul(u64::from(validated.height)) + .ok_or_else(|| format!("平台图集第 {} 个切片像素数量溢出", index + 1))?; + total_decoded_pixels = total_decoded_pixels + .checked_add(decoded_pixels) + .ok_or_else(|| "平台图集切片累计像素数量溢出".to_string())?; + if total_decoded_pixels > PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS { + return Err( + "External Editor 返回的图集切片累计超过 16777216 像素,已拒绝同步".to_string(), + ); + } let declared_width = icon .get("width") .and_then(serde_json::Value::as_u64) .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(decoded.width()); + .unwrap_or(validated.width); let declared_height = icon .get("height") .and_then(serde_json::Value::as_u64) .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(decoded.height()); - if declared_width != decoded.width() || declared_height != decoded.height() { + .unwrap_or(validated.height); + if declared_width != validated.width || declared_height != validated.height { return Err(format!( "平台图集第 {} 个切片尺寸与响应不一致:declared={}x{} actual={}x{}", index + 1, declared_width, declared_height, - decoded.width(), - decoded.height() + validated.width, + validated.height )); } let source_hint = json_string_field(resource, "objectKey") @@ -827,12 +947,21 @@ async fn prepare_platform_art_spritesheet_slices( name: json_string_field(icon, "name") .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| format!("素材 {}", index + 1)), - width: decoded.width(), - height: decoded.height(), + width: validated.width, + height: validated.height, resource_id: json_string_field(resource, "resourceId") .or_else(|| json_string_field(icon, "resourceId")), asset_object_id: json_string_field(resource, "assetObjectId") .or_else(|| json_string_field(icon, "assetObjectId")), + canvas_project_id: json_string_field(resource, "projectId") + .or_else(|| json_string_field(icon, "projectId")), + task_id: json_string_field(resource, "taskId") + .or_else(|| json_string_field(icon, "taskId")), + source_resource_id: json_string_field(resource, "sourceResourceId") + .or_else(|| json_string_field(icon, "sourceResourceId")), + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: validated.has_visible_pixels, extension: extension.to_string(), download, }); @@ -880,6 +1009,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }) .transpose()? .flatten(); + let recovering_generation = persisted_runtime_state.is_some(); if persisted_runtime_state .as_ref() .is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared") @@ -1141,11 +1271,18 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let prepared_output_path = match prepared_output_path_before_submit { Some(prepared) => prepared, - None => prepare_platform_art_asset_output_path_for_mode( - root, - options.output_path.as_deref(), - options.replace_existing, - )?, + None => { + let recovery_path_exists = options + .output_path + .as_deref() + .and_then(|path| resolve_local_project_path(root, path).ok()) + .is_some_and(|path| path.exists()); + prepare_platform_art_asset_output_path_for_mode( + root, + options.output_path.as_deref(), + options.replace_existing || (recovering_generation && recovery_path_exists), + )? + } }; let requested_output_path = prepared_output_path .as_ref() @@ -1173,31 +1310,68 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at resolve_canvas_resource_download(&client, &api_base_url, &api_key, &download_source) .await? .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; - if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) { - return Err( - "External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集" - .to_string(), - ); - } + let (spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, spritesheet_pixels) = + if is_canonical_art_spritesheet { + let (has_transparent, has_visible, pixels) = + platform_art_spritesheet_alpha_contract(&download)?; + if !has_transparent { + return Err( + "External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集" + .to_string(), + ); + } + if !has_visible { + return Err( + "External Editor 返回的 art-spritesheet 全透明且没有可见内容,已拒绝登记为空图集" + .to_string(), + ); + } + (true, true, pixels) + } else { + (false, false, 0) + }; let slice_warning = generated .get("sliceWarning") .filter(|warning| !warning.is_null()) .and_then(|warning| json_string_field(warning, "reason")); let slices = if is_canonical_art_spritesheet { - prepare_platform_art_spritesheet_slices(&client, &api_base_url, &api_key, generated).await? + prepare_platform_art_spritesheet_slices( + &client, + &api_base_url, + &api_key, + generated, + download.bytes.len(), + spritesheet_pixels, + ) + .await? } else { Vec::new() }; let warning = platform_art_generation_warning(generated); let resource_id = json_string_field(resource, "resourceId"); - let task_id = - json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); + let generated_task_id = json_string_field(generated, "taskId"); + let resource_task_id = json_string_field(resource, "taskId"); + if is_canonical_art_spritesheet + && generated_task_id + .as_deref() + .zip(resource_task_id.as_deref()) + .is_some_and(|(generated_task_id, resource_task_id)| { + generated_task_id != resource_task_id + }) + { + return Err("External Editor 图集 taskId 与资源 taskId 不一致,已拒绝提交".to_string()); + } + let task_id = generated_task_id.or(resource_task_id); let asset_object_id = json_string_field(generated, "assetObjectId") .or_else(|| json_string_field(resource, "assetObjectId")) .or_else(|| json_string_field(asset, "assetObjectId")); - let canvas_project_id = json_string_field(resource, "projectId") - .or_else(|| json_string_field(generated, "projectId")) - .or_else(|| Some(canvas_context.project_id.clone())); + let response_canvas_project_id = json_string_field(resource, "projectId") + .or_else(|| json_string_field(generated, "projectId")); + let canvas_project_id = if is_canonical_art_spritesheet { + response_canvas_project_id + } else { + response_canvas_project_id.or_else(|| Some(canvas_context.project_id.clone())) + }; let generated_prompt = json_string_field(generated, "actualPrompt") .or_else(|| json_string_field(generated, "prompt")) .or_else(|| json_string_field(resource, "actualPrompt")) @@ -1231,7 +1405,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at generation_route, generation_kind, reference_resource_ids, + spritesheet_has_transparent_pixels, + spritesheet_has_visible_pixels, extension, + recover_existing_outputs: recovering_generation, }) } @@ -1371,6 +1548,8 @@ struct PlatformArtSliceContractRollback { impl PlatformArtSliceContractRollback { fn capture(root: &Path, suffix: &str) -> Result { let paths = [ + ".agent/manifest.json", + "assets/manifest.art.json", "assets/art-spritesheet-slices/player.png", "assets/art-spritesheet-slices/blocks-and-targets.png", "assets/art-spritesheet-slices/obstacles-and-scene.png", @@ -1423,6 +1602,193 @@ impl PlatformArtSliceContractRollback { } } +fn validate_strict_platform_art_spritesheet_contract( + slices: &[PreparedPlatformArtAssetSlice], + canvas_context: &ExternalCanvasGenerationContext, + canvas_project_id: Option<&str>, + resource_id: Option<&str>, + task_id: Option<&str>, + generation_route: &str, + generation_kind: &str, + reference_resource_ids: &[String], + has_transparent_pixels: bool, + has_visible_pixels: bool, +) -> Result<(), String> { + if slices.len() != 4 { + return Err(format!( + "game-chat 图集必须恰好包含 4 个独立切片,实际为 {} 个", + slices.len() + )); + } + let resource_id = resource_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "game-chat 图集必须包含稳定的 Canvas resourceId,已在本地落盘前拒绝提交".to_string() + })?; + let task_id = task_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "game-chat 图集必须包含稳定的 External Editor taskId,已拒绝提交".to_string() + })?; + if canvas_project_id.map(str::trim) != Some(canvas_context.project_id.as_str()) { + return Err("game-chat 图集响应不属于当前请求的 Canvas projectId,已拒绝提交".to_string()); + } + if generation_route != "/api/external/v1/editor/icon-spritesheets/generations" + || generation_kind != "icon-spritesheet" + { + return Err("game-chat 图集生成 route/kind 与严格图集合同不一致".to_string()); + } + if reference_resource_ids.len() != 1 + || reference_resource_ids[0].trim().is_empty() + || reference_resource_ids[0].trim() == resource_id + { + return Err("game-chat 图集必须绑定唯一且独立的 art-spec resourceId".to_string()); + } + if !has_transparent_pixels || !has_visible_pixels { + return Err("game-chat 图集必须同时包含真实透明像素和非透明可见内容".to_string()); + } + let mut resource_ids = std::collections::HashSet::with_capacity(slices.len()); + let mut pixel_sha256s = std::collections::HashSet::with_capacity(slices.len()); + for (index, slice) in slices.iter().enumerate() { + if !slice.has_visible_pixels { + return Err(format!( + "game-chat 图集第 {} 个切片全透明且没有可见内容", + index + 1 + )); + } + let slice_resource_id = slice + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "game-chat 图集第 {} 个切片缺少稳定 Canvas resourceId", + index + 1 + ) + })?; + if !resource_ids.insert(slice_resource_id) { + return Err("game-chat 图集切片的稳定 Canvas resourceId 重复".to_string()); + } + if slice_resource_id == resource_id { + return Err("game-chat 图集切片 resourceId 不能复用整图 resourceId".to_string()); + } + if slice.canvas_project_id.as_deref().map(str::trim) + != Some(canvas_context.project_id.as_str()) + { + return Err(format!( + "game-chat 图集第 {} 个切片不属于当前请求的 Canvas projectId", + index + 1 + )); + } + if slice.task_id.as_deref().map(str::trim) != Some(task_id) { + return Err(format!( + "game-chat 图集第 {} 个切片未绑定当前 External Editor taskId", + index + 1 + )); + } + if slice.source_resource_id.as_deref().map(str::trim) != Some(resource_id) { + return Err(format!( + "game-chat 图集第 {} 个切片缺少与整图一致的 sourceResourceId", + index + 1 + )); + } + let validated = validate_platform_art_png_bytes_with_limits( + &slice.download.bytes, + &format!("game-chat 图集第 {} 个切片", index + 1), + )?; + if validated.content_sha256 != slice.content_sha256 + || validated.pixel_sha256 != slice.pixel_sha256 + { + return Err(format!( + "game-chat 图集第 {} 个切片内容摘要与待写入字节不一致", + index + 1 + )); + } + if !pixel_sha256s.insert(slice.pixel_sha256.as_str()) { + return Err( + "game-chat 图集切片规范像素摘要重复,无法证明四类素材视觉上相互独立".to_string(), + ); + } + } + Ok(()) +} + +fn strict_game_art_manifest_bytes() -> Vec { + game_chat_fast_path_art_manifest_content().into_bytes() +} + +fn commit_strict_platform_art_slices_at( + root: &Path, + slices: Vec, + source_resource_id: &str, + source_task_id: &str, + source_canvas_project_id: &str, + reference_resource_ids: &[String], + suffix: &str, +) -> Result, String> { + let usages = [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ]; + let mut generated = Vec::with_capacity(slices.len()); + let mut content_sha256s = Vec::with_capacity(slices.len()); + let mut pixel_sha256s = Vec::with_capacity(slices.len()); + for (index, (slice, usage)) in slices.into_iter().zip(usages).enumerate() { + let local_path = format!("assets/art-spritesheet-slices/{usage}.png"); + let absolute_path = resolve_local_project_path(root, &local_path)?; + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建正式平台图集切片目录失败:{}: {error}", + parent.display() + ) + })?; + } + replace_platform_art_slice_file(&absolute_path, &slice.download.bytes, suffix)?; + content_sha256s.push(slice.content_sha256.clone()); + pixel_sha256s.push(slice.pixel_sha256.clone()); + generated.push(GeneratedPlatformArtAssetSlice { + name: slice.name, + width: slice.width, + height: slice.height, + local_path, + resource_id: slice.resource_id, + asset_object_id: slice.asset_object_id, + }); + debug_assert_eq!(index + 1, generated.len()); + } + let manifest = serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": "assets/art-spritesheet.png", + "sourceResourceId": source_resource_id, + "sourceTaskId": source_task_id, + "sourceCanvasProjectId": source_canvas_project_id, + "sourceReferenceResourceIds": reference_resource_ids, + "slices": generated.iter().enumerate().map(|(index, slice)| serde_json::json!({ + "name": slice.name, + "path": slice.local_path, + "width": slice.width, + "height": slice.height, + "usage": usages[index], + "resourceId": slice.resource_id, + "assetObjectId": slice.asset_object_id, + "contentSha256": content_sha256s[index], + "pixelSha256": pixel_sha256s[index], + })).collect::>(), + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("序列化平台图集切片清单失败:{error}"))?; + let manifest_path = + resolve_local_project_path(root, "assets/art-spritesheet-slices/manifest.json")?; + replace_platform_art_slice_file(&manifest_path, &manifest_bytes, suffix)?; + Ok(generated) +} + impl Drop for PlatformArtSliceContractRollback { fn drop(&mut self) { self.restore(); @@ -1553,30 +1919,62 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generation_route, generation_kind, reference_resource_ids, + spritesheet_has_transparent_pixels, + spritesheet_has_visible_pixels, extension, + recover_existing_outputs, } = prepared; + if require_complete_core_slices { + validate_strict_platform_art_spritesheet_contract( + &slices, + &canvas_context, + canvas_project_id.as_deref(), + resource_id.as_deref(), + task_id.as_deref(), + &generation_route, + &generation_kind, + &reference_resource_ids, + spritesheet_has_transparent_pixels, + spritesheet_has_visible_pixels, + )?; + } let file_stem = resource_id .as_deref() .or(task_id.as_deref()) .unwrap_or("platform-art"); - let (local_path, mut absolute_path) = match requested_output_path { + let (local_path, mut absolute_path, output_already_installed) = match requested_output_path { Some(requested_output_path) => { + let recovery_path = resolve_local_project_path(root, &requested_output_path)?; + let recovery_existing = recover_existing_outputs && recovery_path.exists(); let (local_path, absolute_path, current_fingerprint) = prepare_platform_art_asset_output_path_for_mode( root, Some(&requested_output_path), - options.replace_existing, + options.replace_existing || recovery_existing, )? .ok_or_else(|| "图片生成 outputPath 不能为空".to_string())?; if current_fingerprint != replacement_fingerprint { return Err("待替换图片在生成期间发生变化,已拒绝覆盖".to_string()); } + if recovery_existing { + let expected_sha256 = format!("{:x}", Sha256::digest(&download.bytes)); + if current_fingerprint + .as_ref() + .map(|value| value.sha256.as_str()) + != Some(expected_sha256.as_str()) + { + return Err( + "恢复 External Editor 本地提交时发现固定输出路径内容冲突,已拒绝覆盖" + .to_string(), + ); + } + } if !platform_art_asset_output_extension_matches(&local_path, &extension) { return Err(format!( "图片生成结果格式为 {extension},与 outputPath 扩展名不一致" )); } - (local_path, absolute_path) + (local_path, absolute_path, recovery_existing) } None => { let local_path = format!( @@ -1586,26 +1984,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( extension ); let absolute_path = resolve_local_project_path(root, &local_path)?; - (local_path, absolute_path) + (local_path, absolute_path, false) } }; - if require_complete_core_slices && slices.len() != 4 { - return Err(format!( - "game-chat 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); - } - if require_complete_core_slices - && resource_id - .as_deref() - .map(str::trim) - .filter(|resource_id| !resource_id.is_empty()) - .is_none() - { - return Err( - "game-chat 图集必须包含稳定的 Canvas resourceId,已在本地落盘前拒绝提交".to_string(), - ); - } if let Some(parent) = absolute_path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; @@ -1643,17 +2024,33 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( let (slices, strict_generated_slices) = if require_complete_core_slices { ( Vec::new(), - commit_prepared_platform_art_slices_at( + commit_strict_platform_art_slices_at( root, slices, - file_stem, - resource_id.as_deref(), + resource_id + .as_deref() + .expect("strict spritesheet resourceId was validated"), + task_id + .as_deref() + .expect("strict spritesheet taskId was validated"), + canvas_project_id + .as_deref() + .expect("strict spritesheet canvas project was validated"), + &reference_resource_ids, &replacement_suffix, )?, ) } else { (slices, Vec::new()) }; + if require_complete_core_slices { + let art_manifest_path = resolve_local_project_path(root, "assets/manifest.art.json")?; + replace_platform_art_slice_file( + &art_manifest_path, + &strict_game_art_manifest_bytes(), + &replacement_suffix, + )?; + } let output_path = if options.replace_existing { absolute_path.with_file_name(format!( ".{}.replacement.{replacement_suffix}", @@ -1665,22 +2062,24 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( } else { absolute_path.clone() }; - let mut output = fs::OpenOptions::new(); - output.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - output.custom_flags(libc::O_NOFOLLOW); - output.mode(0o600); + if !output_already_installed { + let mut output = fs::OpenOptions::new(); + output.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + output.custom_flags(libc::O_NOFOLLOW); + output.mode(0o600); + } + let mut output = output + .open(&output_path) + .map_err(|error| format!("创建平台生成素材失败:{}: {error}", output_path.display()))?; + output.write_all(&download.bytes).map_err(|error| { + let _ = fs::remove_file(&output_path); + format!("写入平台生成素材失败:{}: {error}", output_path.display()) + })?; + drop(output); } - let mut output = output - .open(&output_path) - .map_err(|error| format!("创建平台生成素材失败:{}: {error}", output_path.display()))?; - output.write_all(&download.bytes).map_err(|error| { - let _ = fs::remove_file(&output_path); - format!("写入平台生成素材失败:{}: {error}", output_path.display()) - })?; - drop(output); let replacement_backup_path = options.replace_existing.then(|| { absolute_path.with_file_name(format!( ".{}.previous.{replacement_suffix}", @@ -1803,7 +2202,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( if let Some(backup_path) = replacement_backup_path.as_ref() { let _ = fs::remove_file(backup_path); } - append_agent_db_record( + let append_result = append_agent_db_record( root, serde_json::json!({ "recordType": "canvas.asset_generate", @@ -1830,7 +2229,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "generationKind": generation_kind, "referenceResourceIds": reference_resource_ids, }), - )?; + ); + if !require_complete_core_slices { + append_result?; + } Ok(GeneratedPlatformArtAsset { asset: registered, slices: generated_slices, @@ -1917,7 +2319,10 @@ pub(crate) fn build_platform_art_asset_prompt( #[cfg(test)] mod canvas_generation_tests { use super::*; - use image::{codecs::png::PngEncoder, ColorType, ImageEncoder}; + use image::{ + codecs::png::{CompressionType, FilterType, PngEncoder}, + ColorType, ImageEncoder, + }; fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream @@ -1963,8 +2368,16 @@ mod canvas_generation_tests { } fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { + rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) + } + + fn rgba_test_png_with_quality( + alpha: u8, + compression: CompressionType, + filter: FilterType, + ) -> CanvasResourceDownload { let mut bytes = Vec::new(); - PngEncoder::new(&mut bytes) + PngEncoder::new_with_quality(&mut bytes, compression, filter) .write_image(&[12, 34, 56, alpha], 1, 1, ColorType::Rgba8.into()) .expect("encode RGBA fixture"); CanvasResourceDownload { @@ -1978,18 +2391,33 @@ mod canvas_generation_tests { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind icon slice download fixture"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let server_base_url = base_url.clone(); let png = rgba_test_png(120).bytes; let server = std::thread::spawn(move || { - for _ in 0..4 { + for _ in 0..8 { let (mut stream, _) = listener.accept().expect("accept slice download"); - let _ = read_test_http_request(&mut stream); - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - png.len() - ) - .expect("write slice response headers"); - stream.write_all(&png).expect("write slice response body"); + let request = read_test_http_request(&mut stream); + if request.contains("/api/external/v1/assets/read-url?") { + let body = serde_json::json!({ + "read": {"signedUrl": format!("{server_base_url}/stable-slice.png")} + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write signed URL response"); + } else { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write slice response headers"); + stream.write_all(&png).expect("write slice response body"); + } } }); let generated = serde_json::json!({ @@ -2001,7 +2429,7 @@ mod canvas_generation_tests { "resource": { "resourceId": format!("slice-resource-{index}"), "assetObjectId": format!("slice-object-{index}"), - "imageSrc": format!("{base_url}/slice-{index}.png"), + "objectKey": format!("stable/slice-{index}.png"), } })).collect::>() }); @@ -2011,6 +2439,8 @@ mod canvas_generation_tests { &base_url, "test-api-key", &generated, + 0, + 0, ) .await .expect("prepare external icon slices"); @@ -2023,6 +2453,28 @@ mod canvas_generation_tests { assert!(slices.iter().all(|slice| slice.extension == "png")); } + #[tokio::test] + async fn spritesheet_slice_download_uses_budget_remaining_after_main_image() { + let generated = serde_json::json!({ + "iconImageSrcs": [{ + "name": "玩家主体", + "imageSrc": "https://cdn.example.test/slice.png" + }] + }); + let error = prepare_platform_art_spritesheet_slices( + &reqwest::Client::new(), + "https://editor.example.test", + "test-api-key", + &generated, + PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES, + 1, + ) + .await + .err() + .expect("main image must consume the shared compressed-byte budget"); + assert!(error.contains("剩余下载预算为 0")); + } + #[tokio::test] async fn generation_submit_response_loss_is_not_retried() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture"); @@ -2838,12 +3290,30 @@ mod canvas_generation_tests { #[test] fn canonical_art_spritesheet_requires_real_transparent_pixels() { - assert!(platform_art_spritesheet_has_transparent_pixels( - &rgba_test_png(0) - )); - assert!(!platform_art_spritesheet_has_transparent_pixels( - &rgba_test_png(u8::MAX) - )); + assert_eq!( + platform_art_spritesheet_alpha_contract(&rgba_test_png(0)) + .expect("decode transparent pixel"), + (true, false, 1) + ); + assert_eq!( + platform_art_spritesheet_alpha_contract(&rgba_test_png(120)) + .expect("decode translucent visible pixel"), + (true, true, 1) + ); + assert_eq!( + platform_art_spritesheet_alpha_contract(&rgba_test_png(u8::MAX)) + .expect("decode opaque pixel"), + (false, true, 1) + ); + } + + #[test] + fn canonical_art_spritesheet_request_has_exactly_four_ordered_categories() { + let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); + assert_eq!(descriptions.len(), 4); + for (index, description) in descriptions.iter().enumerate() { + assert!(description.starts_with(&format!("第 {} 类", index + 1))); + } } #[test] @@ -2985,7 +3455,10 @@ mod canvas_generation_tests { generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], + spritesheet_has_transparent_pixels: true, + spritesheet_has_visible_pixels: true, extension: "png".to_string(), + recover_existing_outputs: false, } } @@ -2997,14 +3470,26 @@ mod canvas_generation_tests { prepared.slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"] .into_iter() .enumerate() - .map(|(index, name)| PreparedPlatformArtAssetSlice { - name: name.to_string(), - width: 1, - height: 1, - download: rgba_test_png(100 + index as u8), - resource_id: Some(format!("replacement-slice-resource-{index}")), - asset_object_id: Some(format!("replacement-slice-object-{index}")), - extension: "png".to_string(), + .map(|(index, name)| { + let download = rgba_test_png(100 + index as u8); + let validated = + validate_platform_art_png_bytes_with_limits(&download.bytes, "test slice") + .expect("validate test slice"); + PreparedPlatformArtAssetSlice { + name: name.to_string(), + width: 1, + height: 1, + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: true, + download, + resource_id: Some(format!("replacement-slice-resource-{index}")), + asset_object_id: Some(format!("replacement-slice-object-{index}")), + canvas_project_id: Some("canvas-project".to_string()), + task_id: Some("replacement-task".to_string()), + source_resource_id: Some("replacement-resource".to_string()), + extension: "png".to_string(), + } }) .collect(); prepared @@ -3188,14 +3673,26 @@ mod canvas_generation_tests { let slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"] .into_iter() .enumerate() - .map(|(index, name)| PreparedPlatformArtAssetSlice { - name: name.to_string(), - width: 1, - height: 1, - download: rgba_test_png(100 + index as u8), - resource_id: Some(format!("slice-resource-{index}")), - asset_object_id: Some(format!("slice-object-{index}")), - extension: "png".to_string(), + .map(|(index, name)| { + let download = rgba_test_png(100 + index as u8); + let validated = + validate_platform_art_png_bytes_with_limits(&download.bytes, "test slice") + .expect("validate test slice"); + PreparedPlatformArtAssetSlice { + name: name.to_string(), + width: 1, + height: 1, + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: true, + download, + resource_id: Some(format!("slice-resource-{index}")), + asset_object_id: Some(format!("slice-object-{index}")), + canvas_project_id: Some("canvas-project".to_string()), + task_id: Some("spritesheet-task".to_string()), + source_resource_id: Some("spritesheet-resource".to_string()), + extension: "png".to_string(), + } }) .collect::>(); let prepared = PreparedPlatformArtAssetGeneration { @@ -3220,7 +3717,10 @@ mod canvas_generation_tests { generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], + spritesheet_has_transparent_pixels: true, + spritesheet_has_visible_pixels: true, extension: "png".to_string(), + recover_existing_outputs: false, }; let mut options = replacement_options(); options.replace_existing = false; @@ -3247,9 +3747,230 @@ mod canvas_generation_tests { assert_eq!(manifest["schemaVersion"], "game-art-slices.v1"); assert_eq!(manifest["source"], "assets/art-spritesheet.png"); assert_eq!(manifest["sourceResourceId"], "spritesheet-resource"); + assert_eq!(manifest["sourceTaskId"], "spritesheet-task"); + assert_eq!(manifest["sourceCanvasProjectId"], "canvas-project"); + assert_eq!( + manifest["sourceReferenceResourceIds"], + serde_json::json!(["art-spec-resource"]) + ); assert_eq!(manifest["slices"].as_array().map(Vec::len), Some(4)); assert_eq!(manifest["slices"][0]["usage"], "player"); assert_eq!(manifest["slices"][3]["usage"], "feedback-effects"); + assert_eq!(manifest["slices"][0]["resourceId"], "slice-resource-0"); + assert!(manifest["slices"][0]["contentSha256"] + .as_str() + .is_some_and(|value| value.len() == 64)); + let art_manifest: serde_json::Value = serde_json::from_slice( + &fs::read(root.join("assets/manifest.art.json")).expect("read art manifest"), + ) + .expect("parse art manifest"); + assert_eq!(art_manifest["status"], "generated"); + assert_eq!( + art_manifest["sliceManifest"], + "assets/art-spritesheet-slices/manifest.json" + ); + let registered = read_manifest_for_project(root).expect("read registered asset manifest"); + assert!(registered.assets.iter().any(|asset| { + asset.local_path == "assets/art-spritesheet.png" + && asset.source.resource_id.as_deref() == Some("spritesheet-resource") + })); + } + + #[test] + fn strict_slice_commit_rejects_duplicate_content_and_identity_before_writing() { + let temporary = tempfile::tempdir().expect("create strict duplicate project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-duplicate", "严格切片唯一性测试") + .expect("init project"); + let setup_path = root.join("assets/art-spritesheet.png"); + fs::write(&setup_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); + fs::remove_file(&setup_path).expect("remove setup sheet"); + prepared.replacement_fingerprint = None; + let alternate_encoding = + rgba_test_png_with_quality(100, CompressionType::Best, FilterType::NoFilter); + assert_ne!( + alternate_encoding.bytes, prepared.slices[0].download.bytes, + "fixture must encode identical pixels into different PNG bytes" + ); + let alternate = validate_platform_art_png_bytes_with_limits( + &alternate_encoding.bytes, + "alternate duplicate slice", + ) + .expect("validate alternate duplicate encoding"); + assert_eq!(alternate.pixel_sha256, prepared.slices[0].pixel_sha256); + prepared.slices[1].download = alternate_encoding; + prepared.slices[1].content_sha256 = alternate.content_sha256; + prepared.slices[1].pixel_sha256 = alternate.pixel_sha256; + let mut options = replacement_options(); + options.replace_existing = false; + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("duplicate strict slices must fail closed"); + + assert!(error.contains("规范像素摘要重复")); + assert!(!setup_path.exists()); + assert!(!root.join("assets/art-spritesheet-slices").exists()); + assert!(!root.join("assets/manifest.art.json").exists()); + } + + #[test] + fn strict_slice_commit_rejects_missing_source_resource_binding() { + let temporary = tempfile::tempdir().expect("create strict source binding project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-source-binding", "严格来源绑定测试") + .expect("init project"); + let setup_path = root.join("assets/art-spritesheet.png"); + fs::write(&setup_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); + fs::remove_file(&setup_path).expect("remove setup sheet"); + prepared.replacement_fingerprint = None; + prepared.slices[2].source_resource_id = None; + let mut options = replacement_options(); + options.replace_existing = false; + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("all strict slices must bind to the generated sheet resource"); + + assert!(error.contains("sourceResourceId")); + assert!(!setup_path.exists()); + assert!(!root.join("assets/art-spritesheet-slices").exists()); + } + + #[test] + fn strict_slice_commit_recovers_idempotently_after_main_file_was_installed() { + let temporary = tempfile::tempdir().expect("create strict crash recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-crash-recovery", "严格崩溃恢复测试") + .expect("init project"); + let setup_path = root.join("assets/art-spritesheet.png"); + fs::write(&setup_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); + fs::write(&setup_path, b"new-image").expect("simulate main image installed before crash"); + prepared.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/art-spritesheet.png") + .expect("fingerprint recovered main image"), + ); + prepared.recover_existing_outputs = true; + let mut options = replacement_options(); + options.replace_existing = false; + + let generated = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect("durable accepted generation must finish an interrupted local commit"); + + assert_eq!( + fs::read(&setup_path).expect("read recovered sheet"), + b"new-image" + ); + assert_eq!(generated.slices.len(), 4); + assert!(root + .join("assets/art-spritesheet-slices/manifest.json") + .is_file()); + assert!(read_manifest_for_project(root) + .expect("read recovered project manifest") + .assets + .iter() + .any(|asset| asset.local_path == "assets/art-spritesheet.png")); + } + + #[test] + fn strict_slice_commit_recovery_rejects_conflicting_main_file() { + let temporary = tempfile::tempdir().expect("create strict crash conflict project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-crash-conflict", "严格崩溃冲突测试") + .expect("init project"); + let setup_path = root.join("assets/art-spritesheet.png"); + fs::write(&setup_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"expected-image"); + fs::write(&setup_path, b"conflicting-image").expect("simulate conflicting output"); + prepared.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/art-spritesheet.png") + .expect("fingerprint conflicting output"), + ); + prepared.recover_existing_outputs = true; + let mut options = replacement_options(); + options.replace_existing = false; + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("recovery must not overwrite a conflicting fixed output"); + + assert!(error.contains("固定输出路径内容冲突")); + assert_eq!( + fs::read(&setup_path).expect("read preserved conflict"), + b"conflicting-image" + ); + assert!(!root.join("assets/art-spritesheet-slices").exists()); + } + + #[test] + fn strict_slice_commit_rolls_back_all_files_and_registration_when_asset_record_fails() { + let temporary = tempfile::tempdir().expect("create strict registration rollback project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-registration", "严格登记原子性测试") + .expect("init project"); + let setup_path = root.join("assets/art-spritesheet.png"); + fs::write(&setup_path, b"temporary-old-image").expect("write setup sheet"); + let mut prepared = prepared_replacement_with_core_slices(root, b"new-image"); + fs::remove_file(&setup_path).expect("remove setup sheet"); + prepared.replacement_fingerprint = None; + let mut options = replacement_options(); + options.replace_existing = false; + let old_art_manifest = b"old-art-manifest".to_vec(); + fs::write(root.join("assets/manifest.art.json"), &old_art_manifest) + .expect("write old art manifest"); + let project_manifest_path = root.join(".agent/manifest.json"); + let old_project_manifest = fs::read(&project_manifest_path).expect("read old manifest"); + let failure_marker = root.join(".agent/runtime/test-fail-next-agent-db-record"); + if let Some(parent) = failure_marker.parent() { + fs::create_dir_all(parent).expect("create failure marker parent"); + } + fs::write(&failure_marker, "asset.register").expect("write failure marker"); + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("asset registration failure must roll back strict contract"); + + assert!(error.contains("测试注入 Agent DB 记录失败")); + assert!(!setup_path.exists()); + for usage in [ + "player", + "blocks-and-targets", + "obstacles-and-scene", + "feedback-effects", + ] { + assert!(!root + .join(format!("assets/art-spritesheet-slices/{usage}.png")) + .exists()); + } + assert!(!root + .join("assets/art-spritesheet-slices/manifest.json") + .exists()); + assert_eq!( + fs::read(root.join("assets/manifest.art.json")).expect("read restored art manifest"), + old_art_manifest + ); + assert_eq!( + fs::read(project_manifest_path).expect("read restored project manifest"), + old_project_manifest + ); + assert!(read_manifest_for_project(root) + .expect("read rolled back manifest") + .assets + .is_empty()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 16bcd0a62..a1730b8f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -390,15 +390,23 @@ pub(super) fn mark_platform_art_generation_runtime_legacy_completed( fn safe_legacy_media_reference(value: &str) -> Option { let value = value.trim(); - (value.starts_with('/') && !value.contains(['?', '#'])).then(|| value.to_string()) + (value.starts_with('/') + && !value.starts_with("//") + && !value.contains(['?', '#', '\\']) + && !value.chars().any(char::is_control) + && !value.split('/').any(|segment| segment == "..")) + .then(|| value.to_string()) } fn safe_legacy_object_key(value: &str) -> Option { let value = value.trim(); (!value.is_empty() + && !value.starts_with(['/', '\\']) && !value.starts_with("http://") && !value.starts_with("https://") - && !value.contains(['?', '#'])) + && !value.contains(['?', '#', '\\']) + && !value.chars().any(char::is_control) + && !value.split('/').any(|segment| segment == "..")) .then(|| value.to_string()) } @@ -421,6 +429,7 @@ fn durable_legacy_generation_object( "projectId", "taskId", "assetObjectId", + "sourceResourceId", "actualPrompt", "prompt", "model", @@ -449,6 +458,11 @@ fn durable_legacy_generation_result( result: &serde_json::Value, ) -> Result { let mut durable = durable_legacy_generation_object(result); + for field in ["spritesheetWidth", "spritesheetHeight"] { + if let Some(value) = result.get(field).and_then(serde_json::Value::as_u64) { + durable.insert(field.to_string(), serde_json::Value::from(value)); + } + } for field in [ "resource", "spritesheetResource", @@ -473,6 +487,53 @@ fn durable_legacy_generation_result( } } } + if let Some(icons) = result + .get("iconImageSrcs") + .and_then(serde_json::Value::as_array) + { + if icons.len() > 64 { + return Err("External Editor 旧同步结果的图集切片超过 64 个".to_string()); + } + let mut durable_icons = Vec::with_capacity(icons.len()); + for (index, icon) in icons.iter().enumerate() { + let mut durable_icon = durable_legacy_generation_object(icon); + for field in ["name"] { + copy_legacy_string_field(icon, &mut durable_icon, field); + } + for field in ["width", "height"] { + if let Some(value) = icon.get(field).and_then(serde_json::Value::as_u64) { + durable_icon.insert(field.to_string(), serde_json::Value::from(value)); + } + } + if let Some(resource) = icon.get("resource").filter(|value| value.is_object()) { + let resource = durable_legacy_generation_object(resource); + if !resource.is_empty() { + durable_icon + .insert("resource".to_string(), serde_json::Value::Object(resource)); + } + } + let has_safe_download = |value: &serde_json::Value| { + json_string_field(value, "objectKey").is_some() + || json_string_field(value, "imageSrc").is_some() + }; + let durable_icon_value = serde_json::Value::Object(durable_icon); + if !has_safe_download(&durable_icon_value) + && !durable_icon_value + .get("resource") + .is_some_and(has_safe_download) + { + return Err(format!( + "External Editor 旧同步结果的第 {} 个图集切片缺少可安全持久化的下载引用", + index + 1 + )); + } + durable_icons.push(durable_icon_value); + } + durable.insert( + "iconImageSrcs".to_string(), + serde_json::Value::Array(durable_icons), + ); + } let durable = serde_json::Value::Object(durable); let has_safe_download = |value: &serde_json::Value| { json_string_field(value, "objectKey").is_some() @@ -882,6 +943,21 @@ mod external_generation_state_tests { "objectKey": "generated/legacy.png", "imageSrc": "https://signed.example.test/legacy.png?token=secret" }, + "iconImageSrcs": [{ + "name": "玩家主体", + "width": 64, + "height": 64, + "resource": { + "resourceId": "legacy-slice-resource", + "assetObjectId": "legacy-slice-object", + "projectId": "canvas-project", + "taskId": "legacy-task", + "sourceResourceId": "legacy-resource", + "objectKey": "generated/legacy-slice.png", + "imageSrc": "https://signed.example.test/legacy-slice.png?token=secret" + }, + "unknownSliceField": "drop" + }], "warning": { "code": "source-only", "reason": "保留原图", "secret": "drop" }, "unknownSensitiveField": "drop-me" }), @@ -892,6 +968,25 @@ mod external_generation_state_tests { assert_eq!(durable["resource"]["resourceId"], "legacy-resource"); assert_eq!(durable["resource"]["objectKey"], "generated/legacy.png"); assert!(durable["resource"].get("imageSrc").is_none()); + assert_eq!(durable["iconImageSrcs"].as_array().map(Vec::len), Some(1)); + assert_eq!( + durable["iconImageSrcs"][0]["resource"]["resourceId"], + "legacy-slice-resource" + ); + assert_eq!( + durable["iconImageSrcs"][0]["resource"]["objectKey"], + "generated/legacy-slice.png" + ); + assert_eq!( + durable["iconImageSrcs"][0]["resource"]["sourceResourceId"], + "legacy-resource" + ); + assert!(durable["iconImageSrcs"][0]["resource"] + .get("imageSrc") + .is_none()); + assert!(durable["iconImageSrcs"][0] + .get("unknownSliceField") + .is_none()); assert!(durable.get("unknownSensitiveField").is_none()); assert!(durable["warning"].get("secret").is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 441981ab9..7adf45b85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -79,6 +79,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( loop_index: usize, mcp_catalog: &GameCreatorMcpCatalog, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> { + let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?; let (llm, config_path, context, repository_context_fingerprint, prompt_observations) = build_game_creator_background_agent_context( root, @@ -164,7 +165,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( context_preload_notice = context_preload_notice, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, context = context, - task = task, + task = effective_task, steers_json = steers_json, observations_json = observations_json, canvas_asset_kind_catalog = canvas_asset_kind_catalog, @@ -205,7 +206,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )); system_prompt.push_str(AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_PAYLOAD_GUIDANCE); let playtest_scenario = - autonomous_playtest_scenario_for_run_at(root, agent_id, run_id, task)?; + autonomous_playtest_scenario_for_run_at(root, agent_id, run_id, &effective_task)?; let playtest_contract = autonomous_playtest_contract_prompt(playtest_scenario); system_prompt.push_str("\n\n"); system_prompt.push_str(playtest_contract); @@ -261,6 +262,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?; let (llm, config_path, context, _repository_context_fingerprint, prompt_observations) = build_game_creator_background_agent_context( root, @@ -289,7 +291,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( "开发者" }; let prompt = format!( - "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" + "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" ); let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { game_creator_project_supervisor_chat_system_prompt() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 685e48136..29e951e8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -432,14 +432,42 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_with_session_filter_at( } } } - if !state.run_id.trim().is_empty() && !state.current_task.trim().is_empty() { - match autonomous_effective_root_task_at( + if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !state.run_id.trim().is_empty() + && !state.current_task.trim().is_empty() + { + let task_identity = read_latest_game_creator_agent_runtime_task_by_run_id( root, &state.agent_id, &state.run_id, - &state.current_task, - ) { - Ok(effective_task) => state.current_task = effective_task, + ) + .and_then(|record| { + let Some(record) = record else { + return Ok(None); + }; + let state_effective_task = autonomous_effective_root_task_at( + root, + &state.agent_id, + &state.run_id, + &state.current_task, + )?; + let journal_effective_task = autonomous_effective_root_task_at( + root, + &state.agent_id, + &state.run_id, + &record.task, + )?; + if state_effective_task != journal_effective_task { + return Err("自主构建 Runtime 与 journal 的有效根任务不一致".to_string()); + } + Ok(Some(record.task)) + }); + match task_identity { + // current_task 是 Runtime/task/provider/context 的持久身份锚点,必须保持 + // journal 原文;自主续跑的原始玩法语义只通过 effective root task 读取。 + Ok(Some(journal_task)) => state.current_task = journal_task, + Ok(None) => {} Err(error) => { state.status = "failed".to_string(); state.phase = "needs-reconciliation".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs index 0625571fe..3c3d15062 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -312,6 +312,22 @@ fn game_chat_fast_path_has_art_manifest(root: &Path) -> bool { pub(in crate::agent) fn game_chat_fast_path_art_slice_paths( root: &Path, ) -> Result, String> { + Ok(game_chat_fast_path_validated_art_slices(root)? + .into_iter() + .map(|slice| slice.path) + .collect()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) struct GameChatValidatedArtSlice { + pub(in crate::agent) path: String, + pub(in crate::agent) width: u32, + pub(in crate::agent) height: u32, +} + +pub(in crate::agent) fn game_chat_fast_path_validated_art_slices( + root: &Path, +) -> Result, String> { let file = read_local_project_file_at(root, "assets/art-spritesheet-slices/manifest.json")?; let manifest: serde_json::Value = serde_json::from_str(&file.content) .map_err(|error| format!("game-chat 图集切片清单不是有效 JSON:{error}"))?; @@ -348,7 +364,9 @@ pub(in crate::agent) fn game_chat_fast_path_art_slice_paths( "obstacles-and-scene", "feedback-effects", ]; - let mut paths = Vec::with_capacity(required_usages.len()); + let mut validated_slices = Vec::with_capacity(required_usages.len()); + let mut total_bytes = 0usize; + let mut pixel_sha256s = std::collections::HashSet::with_capacity(required_usages.len()); for usage in required_usages { let slice = slices .iter() @@ -361,13 +379,63 @@ pub(in crate::agent) fn game_chat_fast_path_art_slice_paths( .filter(|path| *path == expected_path) .ok_or_else(|| format!("game-chat {usage} 切片路径无效"))?; let absolute = resolve_local_project_path(root, path)?; + let file_bytes = usize::try_from( + fs::metadata(&absolute) + .map_err(|error| format!("game-chat {usage} 切片无法读取元数据:{error}"))? + .len(), + ) + .map_err(|_| format!("game-chat {usage} 切片大小溢出"))?; + if file_bytes > 20 * 1024 * 1024 { + return Err(format!("game-chat {usage} 切片超过 20 MiB 校验上限")); + } + total_bytes = total_bytes + .checked_add(file_bytes) + .ok_or_else(|| "game-chat 图集切片累计大小溢出".to_string())?; + if total_bytes > 32 * 1024 * 1024 { + return Err("game-chat 图集切片累计超过 32 MiB 校验上限".to_string()); + } let bytes = fs::read(&absolute) .map_err(|error| format!("game-chat {usage} 切片无法读取:{error}"))?; - image::load_from_memory(&bytes) - .map_err(|error| format!("game-chat {usage} 切片无法解码:{error}"))?; - paths.push(path.to_string()); + if bytes.len() != file_bytes { + return Err(format!("game-chat {usage} 切片在读取期间发生变化")); + } + let validated = validate_platform_art_png_bytes_with_limits( + &bytes, + &format!("game-chat {usage} 切片"), + )?; + let expected_width = slice + .get("width") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()); + let expected_height = slice + .get("height") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()); + if expected_width != Some(validated.width) || expected_height != Some(validated.height) { + return Err(format!("game-chat {usage} 切片尺寸与清单不一致")); + } + if slice + .get("contentSha256") + .and_then(serde_json::Value::as_str) + != Some(validated.content_sha256.as_str()) + || slice.get("pixelSha256").and_then(serde_json::Value::as_str) + != Some(validated.pixel_sha256.as_str()) + { + return Err(format!("game-chat {usage} 切片内容摘要与清单不一致")); + } + if !validated.has_visible_pixels { + return Err(format!("game-chat {usage} 切片全透明且没有可见内容")); + } + if !pixel_sha256s.insert(validated.pixel_sha256) { + return Err("game-chat 四类切片存在相同规范像素内容".to_string()); + } + validated_slices.push(GameChatValidatedArtSlice { + path: path.to_string(), + width: validated.width, + height: validated.height, + }); } - Ok(paths) + Ok(validated_slices) } pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( @@ -850,15 +918,15 @@ const FALLBACK_GAME_HTML: &str = r###" context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132); if (artReady) { for (let tile = 0; tile < 8; tile += 1) { - context.drawImage(sceneArt, tile * 120 + 12, canvas.height - 106, 96, 80); + context.drawImage(sceneArt, Math.min(canvas.width - 96, Math.max(0, tile * 120 + 12)), Math.min(canvas.height - 80, Math.max(0, canvas.height - 106)), 96, 80); } - context.drawImage(playerArt, playerX - 56, canvas.height - 198, 112, 112); + context.drawImage(playerArt, Math.min(canvas.width - 112, Math.max(0, playerX - 56)), Math.min(canvas.height - 112, Math.max(0, canvas.height - 198)), 112, 112); } const targetY = 192 + Math.sin(pulse) * 5; if (artReady) { - context.drawImage(targetArt, targetX - 56, targetY - 56, 112, 112); + context.drawImage(targetArt, Math.min(canvas.width - 112, Math.max(0, targetX - 56)), Math.min(canvas.height - 112, Math.max(0, targetY - 56)), 112, 112); if (state.score > 0) { - context.drawImage(feedbackArt, playerX + 32, canvas.height - 222, 72, 72); + context.drawImage(feedbackArt, Math.min(canvas.width - 72, Math.max(0, playerX + 32)), Math.min(canvas.height - 72, Math.max(0, canvas.height - 222)), 72, 72); } } context.fillStyle = '#eaf4ff'; @@ -1118,7 +1186,7 @@ const FALLBACK_TETRIS_GAME_HTML: &str = r###" function draw() { context.clearRect(0, 0, canvas.width, canvas.height); - if (artReady) context.drawImage(sceneArt, 0, 0, canvas.width, canvas.height); + if (artReady) context.drawImage(sceneArt, 0, 0, 360, 600); context.fillStyle = '#071426cc'; context.fillRect(BOARD_X, 0, COLS * CELL, ROWS * CELL); context.strokeStyle = '#315b7f88'; @@ -1129,6 +1197,8 @@ const FALLBACK_TETRIS_GAME_HTML: &str = r###" context.beginPath(); context.moveTo(BOARD_X, y * CELL); context.lineTo(BOARD_X + COLS * CELL, y * CELL); context.stroke(); } if (artReady) { + context.drawImage(playerArt, 8, 8, 48, 48); + context.drawImage(targetArt, 8, 64, 48, 48); board.forEach((row, y) => row.forEach((cell, x) => { if (cell) drawCell(targetArt, x, y, .92); })); if (current) current.shape.forEach((row, rowIndex) => row.forEach((cell, columnIndex) => { if (cell) drawCell(playerArt, current.x + columnIndex, current.y + rowIndex); @@ -1262,12 +1332,18 @@ mod tests { ) .save(root.join(&path)) .expect("write fast path slice fixture"); + let bytes = fs::read(root.join(&path)).expect("read fast path slice fixture"); + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, "fast path slice fixture") + .expect("validate fast path slice fixture"); serde_json::json!({ "name": format!("素材 {}", index + 1), "path": path, "width": 32, "height": 32, "usage": usage, + "contentSha256": validated.content_sha256, + "pixelSha256": validated.pixel_sha256, }) }) .collect::>(); @@ -1294,6 +1370,58 @@ mod tests { ); } + #[test] + fn art_slice_completion_validation_rejects_tampering_and_duplicate_pixels() { + let temporary = tempfile::tempdir().expect("create slice validation project"); + let root = temporary.path(); + init_local_game_project_at(root, "slice-validation", "切片完成门测试") + .expect("init project"); + register_fast_path_visual_fixture( + root, + "assets/art-spritesheet.png", + "art-spritesheet", + vec!["fast-path-art-spec-resource".to_string()], + ); + write_fast_path_art_slice_fixture(root); + assert_eq!( + game_chat_fast_path_validated_art_slices(root) + .expect("fresh strict slice contract is valid") + .len(), + 4 + ); + + let player_path = root.join("assets/art-spritesheet-slices/player.png"); + let targets_path = root.join("assets/art-spritesheet-slices/blocks-and-targets.png"); + fs::copy(&player_path, &targets_path).expect("replace targets with duplicate pixels"); + let duplicate_bytes = fs::read(&targets_path).expect("read duplicate slice"); + let duplicate = validate_platform_art_png_bytes_with_limits( + &duplicate_bytes, + "duplicate completion slice", + ) + .expect("validate duplicate slice"); + let manifest_path = root.join("assets/art-spritesheet-slices/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read slice manifest")) + .expect("parse slice manifest"); + let target = manifest["slices"] + .as_array_mut() + .expect("slice manifest array") + .iter_mut() + .find(|slice| slice["usage"] == "blocks-and-targets") + .expect("target slice manifest"); + target["contentSha256"] = serde_json::json!(duplicate.content_sha256); + target["pixelSha256"] = serde_json::json!(duplicate.pixel_sha256); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize tampered manifest"), + ) + .expect("write tampered manifest"); + + let error = game_chat_fast_path_validated_art_slices(root) + .expect_err("updating the manifest cannot legitimize duplicate visual content"); + assert!(error.contains("相同规范像素")); + } + #[test] fn fallback_html_satisfies_playable_contract() { let html = render_game_chat_fast_path_html("星河收集挑战"); 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 6cae619af..00ec7c831 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 @@ -93,12 +93,18 @@ fn register_game_chat_art_spritesheet_fixture(root: &Path) { image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180])) .save(root.join(&path)) .expect("write game-chat slice fixture"); + let bytes = fs::read(root.join(&path)).expect("read game-chat slice fixture"); + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, "game-chat slice fixture") + .expect("validate game-chat slice fixture"); serde_json::json!({ "name": format!("素材 {}", index + 1), "path": path, "width": 32, "height": 32, "usage": usage, + "contentSha256": validated.content_sha256, + "pixelSha256": validated.pixel_sha256, }) }) .collect::>(); 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 a935d1267..772b2d39a 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 @@ -410,15 +410,28 @@ fn autonomous_code_prototype_art_asset_reference_gap_at( sanitize_agent_runtime_text(&error, 240) ))); } - let asset_dimensions = manifest + let absolute_asset_path = manifest .assets .iter() .find(|asset| asset.local_path == asset_path) .and_then(|asset| resolve_local_project_path(root, &asset.local_path).ok()) - .and_then(|path| fs::read(path).ok()) - .and_then(|bytes| image::load_from_memory(&bytes).ok()) - .map(|image| (image.width(), image.height())) .ok_or_else(|| format!("无法读取已验证视觉资产尺寸:{asset_path}"))?; + let asset_bytes_len = usize::try_from( + fs::metadata(&absolute_asset_path) + .map_err(|error| format!("无法读取已验证视觉资产元数据:{error}"))? + .len(), + ) + .map_err(|_| "已验证视觉资产大小溢出".to_string())?; + if asset_bytes_len > 20 * 1024 * 1024 { + return Err("已验证视觉资产超过 20 MiB 校验上限".to_string()); + } + let asset_bytes = fs::read(&absolute_asset_path) + .map_err(|error| format!("无法读取已验证视觉资产:{error}"))?; + if asset_bytes.len() != asset_bytes_len { + return Err("已验证视觉资产在读取期间发生变化".to_string()); + } + let asset = validate_platform_art_png_bytes_with_limits(&asset_bytes, "已验证视觉资产")?; + let asset_dimensions = (asset.width, asset.height); let Some((_, html)) = read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, @@ -455,19 +468,15 @@ fn game_index_missing_visible_art_slice( root: &Path, html: &[u8], ) -> Result, String> { - let slice_paths = game_chat_fast_path_art_slice_paths(root).map_err(|error| { + let slices = game_chat_fast_path_validated_art_slices(root).map_err(|error| { format!( "assets/art-spritesheet-slices/manifest.json(invalid:{})", sanitize_agent_runtime_text(&error, 240) ) })?; - for slice_path in slice_paths { - let dimensions = resolve_local_project_path(root, &slice_path) - .ok() - .and_then(|path| fs::read(path).ok()) - .and_then(|bytes| image::load_from_memory(&bytes).ok()) - .map(|image| (image.width(), image.height())) - .ok_or_else(|| format!("无法读取已验证视觉切片尺寸:{slice_path}"))?; + for slice in slices { + let slice_path = slice.path; + let dimensions = (slice.width, slice.height); if !game_index_visibly_uses_visual_asset( html, &slice_path, @@ -879,6 +888,7 @@ fn javascript_named_function_is_reachable( cursor = call + marker.len(); if (*definition_start..*definition_end).contains(&call) || position_is_inside_javascript_string(content, call) + || javascript_position_is_in_literal_false_block(content, call) { continue; } @@ -905,6 +915,9 @@ fn javascript_position_is_reachable( ranges: &[(String, usize, usize)], position: usize, ) -> bool { + if javascript_position_is_in_literal_false_block(content, position) { + return false; + } let enclosing = ranges .iter() .enumerate() @@ -916,6 +929,66 @@ fn javascript_position_is_reachable( }) } +fn javascript_position_is_in_literal_false_block(content: &str, position: usize) -> bool { + let statement_start = content[..position.min(content.len())] + .rfind([';', '{', '}']) + .map(|index| index + 1) + .unwrap_or_default(); + let statement_prefix = content[statement_start..position.min(content.len())] + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let short_circuit_prefix = statement_prefix.trim_end_matches('('); + if ["false&&", "0&&", "false?"] + .iter() + .any(|marker| short_circuit_prefix.ends_with(marker)) + { + return true; + } + for marker in [ + "if(false)", + "if (false)", + "if(0)", + "if (0)", + "if(!true)", + "if (!true)", + "while(false)", + "while (false)", + "while(0)", + "while (0)", + ] { + let mut cursor = 0; + while let Some(offset) = content[cursor..position.min(content.len())].find(marker) { + let condition = cursor + offset; + cursor = condition + marker.len(); + if position_is_inside_javascript_string(content, condition) { + continue; + } + let statement_start = cursor + + content[cursor..] + .bytes() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(content.len().saturating_sub(cursor)); + if content.as_bytes().get(statement_start) == Some(&b'{') { + if matching_javascript_brace(content, statement_start) + .is_some_and(|end| (statement_start..=end).contains(&position)) + { + return true; + } + } else { + let statement_end = content[statement_start..] + .find(';') + .map(|offset| statement_start + offset) + .unwrap_or(statement_start); + if (statement_start..=statement_end).contains(&position) { + return true; + } + } + } + } + false +} + fn identifier_before(content: &str, position: usize) -> Option { let bytes = content.as_bytes(); let mut end = position; @@ -931,6 +1004,7 @@ fn identifier_before(content: &str, position: usize) -> Option { fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { let mut identifiers = BTreeSet::new(); + let mut latest_src_assignments = std::collections::BTreeMap::new(); let mut cursor = 0; while let Some(offset) = content[cursor..].find(".src") { let dot = cursor + offset; @@ -971,13 +1045,19 @@ fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> B else { continue; }; - if relative_visual_url_resolves_to_asset( - &content[value_start..value_start + end_offset], - asset_path, - ) { - identifiers.insert(identifier); - } + latest_src_assignments.insert( + identifier, + relative_visual_url_resolves_to_asset( + &content[value_start..value_start + end_offset], + asset_path, + ), + ); } + identifiers.extend( + latest_src_assignments + .iter() + .filter_map(|(identifier, matches)| matches.then_some(identifier.clone())), + ); for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { let Some(id) = html_attribute_value(tag, "id") else { @@ -1015,6 +1095,9 @@ fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> B continue; } if let Some(identifier) = identifier_before(content, equals) { + if latest_src_assignments.get(&identifier) == Some(&false) { + continue; + } identifiers.insert(identifier); } } @@ -1209,15 +1292,22 @@ fn game_index_visibly_uses_visual_asset( style_cursor = body_end + "".len(); } - let active_canvas = markup.split('>').any(|tag| { - tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']) - .starts_with("canvas") + let Some(canvas_dimensions) = markup.split('>').find_map(|tag| { + let is_canvas = tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas"); + (is_canvas && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) - && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150)) - }); - if !active_canvas { + && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150))) + .then(|| { + ( + tag_dimension(tag, "width", "width").unwrap_or(300.0), + tag_dimension(tag, "height", "height").unwrap_or(150.0), + ) + }) + }) else { return false; - } + }; let identifiers = canvas_visual_identifiers(&content, &markup, &asset_path); if identifiers.is_empty() { return false; @@ -1249,13 +1339,13 @@ fn game_index_visibly_uses_visual_asset( } if requirement == VisualAssetUsageRequirement::CanvasDraw && matches!(arguments.len(), 5 | 9) - && draw_image_has_visible_destination(&arguments) + && draw_image_has_visible_destination(&arguments, canvas_dimensions) { return true; } if requirement == VisualAssetUsageRequirement::AtlasCanvasCrop && arguments.len() == 9 - && draw_image_has_visible_destination(&arguments) + && draw_image_has_visible_destination(&arguments, canvas_dimensions) { return true; } @@ -1265,19 +1355,91 @@ fn game_index_visibly_uses_visual_asset( requirement == VisualAssetUsageRequirement::AnyVisible && significant_draws > 0 } -fn draw_image_has_visible_destination(arguments: &[&str]) -> bool { - let (width_index, height_index) = match arguments.len() { - 5 => (3, 4), - 9 => (7, 8), +fn dynamic_canvas_coordinate_is_bounded(value: &str, axis_extent: &str) -> bool { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + let has_lower_bound = compact.contains("math.max(0,"); + let has_upper_bound = compact.contains("math.min(") + && [ + format!("canvas.{axis_extent}"), + format!("gamecanvas.{axis_extent}"), + format!("ctx.canvas.{axis_extent}"), + format!("context.canvas.{axis_extent}"), + ] + .iter() + .any(|marker| compact.contains(marker)); + has_lower_bound && has_upper_bound +} + +fn dynamic_canvas_dimension_fallback(value: &str) -> Option { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + for separator in ["||", "??"] { + if let Some((_, fallback)) = compact.rsplit_once(separator) { + if let Ok(value) = fallback + .trim_matches(|character| matches!(character, '(' | ')')) + .parse::() + { + return Some(value); + } + } + } + compact + .strip_prefix("math.max(") + .and_then(|tail| tail.split(',').next()) + .and_then(|minimum| minimum.parse::().ok()) +} + +fn draw_image_has_visible_destination(arguments: &[&str], canvas_dimensions: (f64, f64)) -> bool { + let (x_index, y_index, width_index, height_index) = match arguments.len() { + 5 => (1, 2, 3, 4), + 9 => (5, 6, 7, 8), _ => return false, }; let parse = |value: &str| value.trim().parse::().ok(); - match parse(arguments[width_index]).zip(parse(arguments[height_index])) { - Some((width, height)) => { - width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + let dimensions = parse(arguments[width_index]) + .or_else(|| dynamic_canvas_dimension_fallback(arguments[width_index])) + .zip( + parse(arguments[height_index]) + .or_else(|| dynamic_canvas_dimension_fallback(arguments[height_index])), + ); + let Some((width, height)) = dimensions else { + return false; + }; + if !width.is_finite() + || !height.is_finite() + || width.abs() < 32.0 + || height.abs() < 32.0 + || width.abs() * height.abs() < 2048.0 + { + return false; + } + + match parse(arguments[x_index]).zip(parse(arguments[y_index])) { + Some((x, y)) if x.is_finite() && y.is_finite() => { + let (canvas_width, canvas_height) = canvas_dimensions; + if x >= canvas_width || y >= canvas_height { + return false; + } + let left = x.min(x + width); + let right = x.max(x + width); + let top = y.min(y + height); + let bottom = y.max(y + height); + let visible_width = right.min(canvas_width) - left.max(0.0); + let visible_height = bottom.min(canvas_height) - top.max(0.0); + visible_width >= 16.0 + && visible_height >= 16.0 + && visible_width * visible_height >= 512.0 } - None => { - !arguments[width_index].trim().is_empty() && !arguments[height_index].trim().is_empty() + _ => { + dynamic_canvas_coordinate_is_bounded(arguments[x_index], "width") + && dynamic_canvas_coordinate_is_bounded(arguments[y_index], "height") } } } @@ -1945,7 +2107,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at( root: &Path, task: &AgentRuntimeTaskRecord, ) -> Result, String> { - if task.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + if !agent_runtime_supervisor_source_is_trusted(&task.source) || !is_pure_autonomous_continuation_intent(&task.task) { return Ok(None); @@ -2048,6 +2210,185 @@ pub(in crate::agent) fn autonomous_effective_root_task_at( Ok(inherited.task) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AutonomousInheritedGameplaySemantics { + Collection, + Tetris, +} + +fn inherited_gameplay_semantics(task: &str) -> Option { + let normalized = task.trim().to_ascii_lowercase(); + if ["俄罗斯方块", "方块下落", "tetromino", "tetris"] + .iter() + .any(|keyword| normalized.contains(keyword)) + { + return Some(AutonomousInheritedGameplaySemantics::Tetris); + } + if ["收集", "能量", "采集", "collect", "collection"] + .iter() + .any(|keyword| normalized.contains(keyword)) + { + return Some(AutonomousInheritedGameplaySemantics::Collection); + } + None +} + +fn javascript_without_string_literals(content: &str) -> String { + let mut output = Vec::with_capacity(content.len()); + let mut quote = None; + let mut escaped = false; + for byte in content.bytes() { + if escaped { + output.push(b' '); + escaped = false; + } else if byte == b'\\' && quote.is_some() { + output.push(b' '); + escaped = true; + } else if let Some(active) = quote { + output.push(b' '); + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + output.push(b' '); + } else { + output.push(byte); + } + } + String::from_utf8(output).unwrap_or_default() +} + +fn reachable_named_javascript_function_body<'a>( + content: &'a str, + ranges: &[(String, usize, usize)], + name_markers: &[&str], +) -> Option<&'a str> { + ranges + .iter() + .enumerate() + .find_map(|(index, (name, start, end))| { + let normalized_name = name.to_ascii_lowercase(); + (name_markers + .iter() + .any(|marker| normalized_name.contains(marker)) + && javascript_named_function_is_reachable( + content, + ranges, + index, + &mut BTreeSet::new(), + )) + .then_some(&content[*start..*end]) + }) +} + +fn tetris_executable_semantics_gap(content: &str) -> Option<&'static str> { + let executable = javascript_without_string_literals(content); + let ranges = named_javascript_function_ranges(&executable); + let has_board_state = ["array.from(", "array("] + .iter() + .any(|marker| executable.contains(marker)) + && executable.contains("board["); + if !has_board_state { + return Some("board-state"); + } + let rotation = + reachable_named_javascript_function_body(&executable, &ranges, &["rotate", "rotation"]); + if !rotation.is_some_and(|body| { + (body.contains(".reverse(") && body.contains(".map(")) + || (body.contains("rotation") + && ["+=", "++", "=", "%"] + .iter() + .any(|marker| body.contains(marker))) + }) { + return Some("piece-rotation"); + } + let falling = reachable_named_javascript_function_body( + &executable, + &ranges, + &["stepdown", "drop", "fall", "gravity", "tick"], + ); + if !falling.is_some_and(|body| { + (body.contains(".y") || body.contains("row")) + && ["+=", "++", "+1", "+ 1"] + .iter() + .any(|marker| body.contains(marker)) + }) { + return Some("piece-fall"); + } + let locking = + reachable_named_javascript_function_body(&executable, &ranges, &["merge", "lock", "place"]); + if !locking.is_some_and(|body| body.contains("board[") && body.contains('=')) { + return Some("piece-lock"); + } + let clearing = + reachable_named_javascript_function_body(&executable, &ranges, &["clear", "line", "row"]); + if !clearing.is_some_and(|body| { + (body.contains(".filter(") && body.contains(".every(")) || body.contains(".splice(") + }) { + return Some("line-clear"); + } + None +} + +fn inherited_gameplay_semantics_gap(task: &str, html: &[u8]) -> Option { + let gameplay = inherited_gameplay_semantics(task)?; + let Ok(html) = std::str::from_utf8(html) else { + return Some("invalid-html-utf8".to_string()); + }; + let content = strip_art_reference_comments(html).to_ascii_lowercase(); + let contains_any = |markers: &[&str]| markers.iter().any(|marker| content.contains(marker)); + let missing = match gameplay { + AutonomousInheritedGameplaySemantics::Tetris => { + if !contains_any(&["俄罗斯方块", "tetromino", "tetris"]) { + Some("tetris-identity") + } else { + tetris_executable_semantics_gap(&content) + } + } + AutonomousInheritedGameplaySemantics::Collection => [ + ( + "collection-identity", + contains_any(&["收集", "采集", "collect", "collection"]), + ), + ( + "collection-progression", + contains_any(&["score", "energy", "resource", "得分", "能量", "资源"]), + ), + ] + .into_iter() + .find_map(|(name, present)| (!present).then_some(name)), + }; + missing.map(str::to_string) +} + +fn autonomous_inherited_gameplay_semantics_gap_at( + root: &Path, + contract: &AgentRuntimeAutonomousCompletionContract, + html: &[u8], +) -> Result, String> { + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &contract.agent_id, + &contract.run_id, + )? + .ok_or_else(|| "自主构建玩法连续性核对缺少根 Run journal".to_string())?; + let journal_task_sha256 = format!("{:x}", Sha256::digest(task.task.as_bytes())); + if journal_task_sha256 == contract.task_sha256 { + return Ok(None); + } + if !is_pure_autonomous_continuation_intent(&task.task) { + return Err("自主构建完成合同任务与非续跑 journal 不一致".to_string()); + } + let effective_task = + autonomous_effective_root_task_at(root, &contract.agent_id, &contract.run_id, &task.task)?; + if format!("{:x}", Sha256::digest(effective_task.as_bytes())) != contract.task_sha256 { + return Err("自主构建续跑的有效任务与完成合同不一致".to_string()); + } + Ok(inherited_gameplay_semantics_gap(&effective_task, html) + .map(|gap| format!("game/index.html(inherited-gameplay-semantic-gap:{gap})"))) +} + pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( root: &Path, task: &AgentRuntimeTaskRecord, @@ -2392,7 +2733,7 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at( { return Err("自主构建交互试玩未通过或场景身份不匹配".to_string()); } - let (game_index, _) = read_autonomous_evidence_file_at( + let (game_index, game_index_bytes) = read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, "自主构建游戏入口", @@ -2402,6 +2743,11 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at( if contract.baseline_index_sha256.as_deref() == Some(game_index.sha256.as_str()) { return Err("自主构建游戏入口仍与运行开始时相同".to_string()); } + if let Some(gap) = + autonomous_inherited_gameplay_semantics_gap_at(root, contract, &game_index_bytes)? + { + return Err(format!("自主构建续跑未保持原玩法语义:{gap}")); + } let report_path = relative_project_path(root, &result.evidence.report_path)?; let (report, report_bytes) = read_autonomous_evidence_file_at( root, @@ -2598,13 +2944,13 @@ pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( ), )); } - let current_index = match read_autonomous_evidence_file_at( + let (current_index, current_index_bytes) = match read_autonomous_evidence_file_at( root, AGENT_RUNTIME_GAME_INDEX_PATH, "自主构建游戏入口", AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES, ) { - Ok(Some((digest, _))) => digest, + Ok(Some(value)) => value, Ok(None) => { return Some(autonomous_completion_blocker( "自主构建尚未生成 game/index.html", @@ -2624,6 +2970,21 @@ pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( "必须真正生成可玩的项目入口,不能沿用初始化文件。", )); } + match autonomous_inherited_gameplay_semantics_gap_at(root, &contract, ¤t_index_bytes) { + Ok(Some(gap)) => { + return Some(autonomous_completion_blocker( + "自主构建续跑未保持原玩法语义", + gap, + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "无法核对自主构建续跑的玩法语义", + error, + )); + } + Ok(None) => {} + } let gate = match read_game_creator_agent_runtime_verification_gate( root, &state.agent_id, @@ -2682,3 +3043,58 @@ pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( } None } + +#[cfg(test)] +mod visible_destination_tests { + use super::*; + + #[test] + fn canvas_draw_destination_rejects_offscreen_and_unbounded_dynamic_coordinates() { + let canvas = (320.0, 180.0); + assert!(!draw_image_has_visible_destination( + &["image", "400", "0", "64", "64"], + canvas, + )); + assert!(!draw_image_has_visible_destination( + &["image", "-96", "0", "64", "64"], + canvas, + )); + assert!(!draw_image_has_visible_destination( + &["image", "player.x", "player.y", "64", "64"], + canvas, + )); + assert!(!draw_image_has_visible_destination( + &["image", "16", "16", "player.width", "player.height"], + canvas, + )); + } + + #[test] + fn canvas_draw_destination_keeps_provable_dynamic_and_size_fallbacks() { + let canvas = (320.0, 180.0); + assert!(draw_image_has_visible_destination( + &[ + "image", + "Math.min(canvas.width - 64, Math.max(0, player.x))", + "Math.min(canvas.height - 64, Math.max(0, player.y))", + "64", + "64", + ], + canvas, + )); + assert!(draw_image_has_visible_destination( + &[ + "image", + "16", + "16", + "spriteWidth || 64", + "spriteHeight || 64" + ], + canvas, + )); + assert!(draw_image_has_visible_destination( + &["image", "-32", "16", "64", "64"], + canvas, + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index af125c079..c205a68c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -319,12 +319,18 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) { image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180])) .save(root.join(&path)) .expect("write autonomous slice fixture"); + let bytes = fs::read(root.join(&path)).expect("read autonomous slice fixture"); + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, "autonomous slice fixture") + .expect("validate autonomous slice fixture"); serde_json::json!({ "name": format!("素材 {}", index + 1), "path": path, "width": 32, "height": 32, "usage": usage, + "contentSha256": validated.content_sha256, + "pixelSha256": validated.pixel_sha256, }) }) .collect::>(); @@ -617,6 +623,7 @@ fn autonomous_continuation_intent_is_exact_and_does_not_swallow_new_requirements #[test] fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let original_task = "做一个水晶主题的俄罗斯方块,完成移动、旋转、消行和重开"; let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source( original_task, @@ -697,6 +704,72 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress( .expect("continued runtime keeps an autonomous completion contract"), continuation_contract ); + let mut legacy_hydrated_state = continuation_state.clone(); + legacy_hydrated_state.current_task = original_task.to_string(); + write_game_creator_agent_runtime_state(&root, &legacy_hydrated_state) + .expect("persist legacy successor state with effective task text"); + let recovered = read_game_creator_agent_runtime_for_session_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + Some(&continuation.session_id), + ) + .expect("restart hydration accepts equivalent successor semantics") + .state; + assert_eq!( + recovered.current_task, continuation.task, + "restart hydration must restore the raw journal task identity" + ); + validate_agent_runtime_context_task_parameter(&root, &recovered, original_task) + .expect("legacy effective context task remains semantically equivalent"); + let legacy_bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &recovered, + original_task, + &AgentRuntimeToolPlan::default(), + &[], + recovered.loop_iteration as usize, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("build a legacy effective-task context bundle"); + write_game_creator_agent_runtime_context_bundle(&root, &legacy_bundle) + .expect("persist legacy effective-task context bundle"); + read_game_creator_agent_runtime_context_bundle(&root, &recovered) + .expect("restart accepts an effective-task context bundle") + .expect("legacy effective-task context bundle exists"); + write_game_creator_agent_runtime_state(&root, &recovered) + .expect("persist canonical successor runtime state"); + capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &recovered.agent_id, + &recovered.session_id, + &recovered.run_id, + "planning", + "successor-restart", + recovered.applied_steer_cursor, + ) + .expect("provider snapshot accepts canonical successor hydration"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let (_, _, provider_request, _) = build_game_creator_agent_background_tool_plan_request( + &root, + &recovered.agent_id, + &recovered.session_id, + &recovered.run_id, + &recovered.current_task, + &[], + 0, + &catalog, + ) + .expect("build successor provider request with inherited task semantics"); + let provider_prompt = &provider_request.messages[1].content; + assert!(provider_prompt.contains(original_task), "{provider_prompt}"); + assert!( + !provider_prompt.contains("后台任务:\n继续完成。"), + "provider must not receive the continuation phrase as the business goal: {provider_prompt}" + ); update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Pending) .expect("make one inherited task ready for scheduler validation"); let scheduled = schedule_autonomous_game_build_ready_tasks_at( @@ -780,6 +853,62 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress( .is_empty()); } +#[test] +fn gui_and_cli_pure_continue_inherit_only_within_the_same_source() { + for (source, prefix) in [ + (AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "gui"), + (AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "cli"), + ] { + let original_task = "做一个水晶主题的俄罗斯方块,完成旋转、消行与重开"; + let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source( + original_task, + &format!("{prefix}-same-source-original"), + source, + ); + let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.run_id, + ) + .expect("read same-source original root") + .expect("same-source original root exists"); + append_failed_autonomous_root_projection(&root, &original_record, "failed"); + + let continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + &format!("{prefix}-same-source-continuation"), + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue same-source continuation"); + let continuation_contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &continuation.run_id, + ) + .expect("read same-source continuation contract") + .expect("same-source continuation contract exists"); + assert_eq!( + continuation_contract.task_sha256, original_contract.task_sha256, + "{source} must inherit the failed root contract within one session and source" + ); + assert_eq!( + autonomous_effective_root_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &continuation.run_id, + &continuation.task, + ) + .expect("resolve same-source effective root task"), + original_task + ); + } +} + #[test] fn game_chat_detailed_new_request_after_failure_resets_manifest() { let (_temporary, root, original_state, original_contract) = @@ -828,6 +957,97 @@ fn game_chat_detailed_new_request_after_failure_resets_manifest() { .all(|task| task.status == GameCreationAppTaskStatus::Pending)); } +#[test] +fn inherited_tetris_contract_rejects_a_generic_collection_replacement() { + let original_task = "做一个水晶主题的俄罗斯方块,完成移动、旋转、下落锁定、消行和重开"; + let (_temporary, root, original_state, _original_contract) = autonomous_fixture_with_source( + original_task, + "semantic-tetris-original-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + let original_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.run_id, + ) + .expect("read semantic original root") + .expect("semantic original root exists"); + append_failed_autonomous_root_projection(&root, &original_record, "budget-exhausted"); + + let continuation = append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &original_state.session_id, + "继续", + "semantic-tetris-continuation-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue semantic continuation"); + let state = agent_runtime_state_from_task_record(&continuation); + let contract = read_autonomous_completion_contract( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &continuation.run_id, + ) + .expect("read semantic continuation contract") + .expect("semantic continuation contract exists"); + let collection_html = format!( + "{}", + cropped_spritesheet_game_html() + ); + let revision = advance_game_index_revision(&root, &state, &collection_html); + mark_verification_passed(&root, &state, "game.static_smoke"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) + .expect("collection replacement must not complete an inherited tetris contract"); + assert!(blocker.summary.contains("原玩法语义")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("tetris-identity"))); + + let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("negative semantic continuity fixture".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + let receipt_error = write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect_err("playtest evidence must not bless a different gameplay implementation"); + assert!(receipt_error.contains("原玩法语义")); + + let tetris_html = format!( + "{}", + cropped_spritesheet_game_html() + ); + advance_game_index_revision(&root, &state, &tetris_html); + mark_verification_passed(&root, &state, "game.static_smoke"); + let next_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) + .expect("valid inherited tetris semantics should proceed to the playtest receipt gate"); + assert!(next_blocker.summary.contains("交互试玩回执")); + + let dead_semantics = format!( + "{}", + cropped_spritesheet_game_html() + ); + advance_game_index_revision(&root, &state, &dead_semantics); + mark_verification_passed(&root, &state, "game.static_smoke"); + let dead_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) + .expect("dead strings and empty functions must not satisfy inherited tetris semantics"); + assert!(dead_blocker.summary.contains("原玩法语义")); +} + #[test] fn game_chat_pure_continue_does_not_inherit_across_sessions() { let (_temporary, root, original_state, original_contract) = @@ -1644,6 +1864,29 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() { "guessing one atlas crop must not replace four persisted core slices" ); + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "draw calls reachable only through a literal-false branch must not satisfy visible use" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + let overwritten_blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("an overwritten slice source must not satisfy visible use"); + assert!(overwritten_blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("art-spritesheet-slices/player.png"))); + advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html()); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 6c162f963..b9ed6e360 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -144,12 +144,16 @@ pub(in crate::agent) fn validate_agent_runtime_context_task_parameter( runtime: &AgentRuntimeState, task: &str, ) -> Result<(), String> { - let expected = redact_agent_runtime_project_paths( + let expected = autonomous_effective_root_task_at( root, + &runtime.agent_id, + &runtime.run_id, &runtime.current_task, - AGENT_RUNTIME_TASK_MAX_CHARS, - ); - let actual = redact_agent_runtime_project_paths(root, task, AGENT_RUNTIME_TASK_MAX_CHARS); + )?; + let actual = autonomous_effective_root_task_at(root, &runtime.agent_id, &runtime.run_id, task)?; + let expected = + redact_agent_runtime_project_paths(root, &expected, AGENT_RUNTIME_TASK_MAX_CHARS); + let actual = redact_agent_runtime_project_paths(root, &actual, AGENT_RUNTIME_TASK_MAX_CHARS); if actual != expected { return Err("Agent Runtime 任务参数与当前状态不匹配".to_string()); } @@ -473,12 +477,7 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe || bundle.source != redact_agent_runtime_project_paths(root, &runtime.source, 120) || bundle.run_profile != runtime.run_profile || bundle.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint - || bundle.task - != redact_agent_runtime_project_paths( - root, - &runtime.current_task, - AGENT_RUNTIME_TASK_MAX_CHARS, - ) + || validate_agent_runtime_context_task_parameter(root, runtime, &bundle.task).is_err() || bundle.verification_gate.project_id != bundle.project_id || bundle.verification_gate.agent_id != bundle.agent_id || bundle.verification_gate.run_id != bundle.run_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 321e21aec..893a3be2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -242,7 +242,7 @@ pub(in crate::agent) fn wake_waiting_isolated_join_parent_run_at( .state; if state.run_id != current_task.run_id || state.session_id != current_task.session_id - || state.current_task != current_task.task + || validate_agent_runtime_context_task_parameter(root, &state, ¤t_task.task).is_err() { return Err("动态隔离 Agent parent-wake 的父 run 状态身份不一致".to_string()); } @@ -276,7 +276,8 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( .state; if state.run_id != parent_task.run_id || state.session_id != parent_task.session_id - || state.current_task != parent_task.task + || validate_agent_runtime_context_task_parameter(root, &state, &parent_task.task) + .is_err() || state.phase != "waiting-for-delegate-receipts" { return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string()); @@ -318,7 +319,7 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( .state; if state.run_id != current_task.run_id || state.session_id != current_task.session_id - || state.current_task != current_task.task + || validate_agent_runtime_context_task_parameter(root, &state, ¤t_task.task).is_err() { return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string()); } @@ -376,7 +377,7 @@ pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( .state; if state.run_id != current_task.run_id || state.session_id != current_task.session_id - || state.current_task != current_task.task + || validate_agent_runtime_context_task_parameter(root, &state, ¤t_task.task).is_err() || state.phase != "waiting-for-manifest-tasks" { return Err("manifest parent-wake 的父 run 状态身份不一致".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 740d1c309..68f0a83c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -673,8 +673,18 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } let game_chat_requires_core_slices = if agent_id == "art-asset-plan" { - agent_runtime_root_source_at(root, agent_id, run_id) - .is_ok_and(|source| source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + match agent_runtime_root_source_at(root, agent_id, run_id) { + Ok(source) => source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "无法校验 art-asset-plan 的 root source,已拒绝降级为普通图集语义" + .to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } } else { false }; @@ -742,9 +752,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio if game_chat_requires_core_slices && prepared.slice_count() != 4 { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), summary: format!( - "透明图集生成结果包含 {} 个独立切片;game-chat 必须恰好得到玩家、目标、场景和反馈四类真实素材,已在正式图集登记前失败关闭", + "透明图集生成结果包含 {} 个独立切片;External Editor 已完成并产生 durable 结果,game-chat 必须恰好得到玩家、目标、场景和反馈四类真实素材,已保留账本等待人工对账", prepared.slice_count() ), detail: None, @@ -832,20 +842,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; match committed { Ok(generated) => { - if game_chat_requires_core_slices { - if let Err(error) = write_local_project_file_at( - root, - "assets/manifest.art.json", - &game_chat_fast_path_art_manifest_content(), - ) { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: "透明图集与四类切片已生成,但正式美术清单提交失败".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - } let verification = begin_agent_runtime_project_verification_locked( root, agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 1c7402307..a7ea6d6b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -452,16 +452,157 @@ pub(crate) async fn resolve_canvas_resource_download( api_key: &str, resource: &serde_json::Value, ) -> Result, String> { + resolve_canvas_resource_download_with_limit( + client, + api_base_url, + api_key, + resource, + 20 * 1024 * 1024, + ) + .await +} + +fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool { + let Ok(api_base_url) = url::Url::parse(api_base_url) else { + return false; + }; + url.scheme() == api_base_url.scheme() + && url.host_str() == api_base_url.host_str() + && url.port_or_known_default() == api_base_url.port_or_known_default() +} + +fn external_asset_host_is_private(host: &str) -> bool { + let host = host.trim_start_matches('[').trim_end_matches(']'); + if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") { + return true; + } + let Ok(address) = host.parse::() else { + return false; + }; + match address { + std::net::IpAddr::V4(address) => { + let [first, second, ..] = address.octets(); + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_broadcast() + || address.is_unspecified() + || address.is_multicast() + || first == 0 + || (first == 100 && (64..=127).contains(&second)) + || (first == 198 && (18..=19).contains(&second)) + } + std::net::IpAddr::V6(address) => { + address.is_loopback() + || address.is_unspecified() + || address.is_unique_local() + || address.is_unicast_link_local() + || address.is_multicast() + || address + .to_ipv4_mapped() + .is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string())) + } + } +} + +fn validate_external_asset_download_url( + value: &str, + api_base_url: &str, + _came_from_stable_reference: bool, +) -> Result { + let url = url::Url::parse(value).map_err(|error| format!("画板资产下载地址无效:{error}"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err("画板资产下载地址只允许 HTTP(S)".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("画板资产下载地址不能包含用户凭据".to_string()); + } + let host = url + .host_str() + .ok_or_else(|| "画板资产下载地址缺少主机".to_string())?; + // 配置中的 External Editor 本身可以是 localhost;同源媒体仍停留在这条已授权 + // 边界内。任何跨 origin 的私网地址均拒绝,且下载客户端不跟随重定向。 + if external_asset_host_is_private(host) && !external_asset_url_same_origin(&url, api_base_url) { + return Err("画板资产下载地址指向本机或私有网络,已拒绝请求".to_string()); + } + Ok(url) +} + +async fn build_external_asset_download_client( + url: &url::Url, + api_base_url: &str, +) -> Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::none()); + let host = url + .host_str() + .ok_or_else(|| "画板资产下载地址缺少主机".to_string())?; + let host_is_literal_or_local = host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok() + || host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost"); + if !host_is_literal_or_local && !external_asset_url_same_origin(url, api_base_url) { + let lookup_host = host.to_string(); + let lookup_port = url + .port_or_known_default() + .ok_or_else(|| "画板资产下载地址缺少有效端口".to_string())?; + let addresses = tokio::task::spawn_blocking(move || { + std::net::ToSocketAddrs::to_socket_addrs(&(lookup_host.as_str(), lookup_port)) + .map(|addresses| addresses.collect::>()) + }) + .await + .map_err(|_| "解析画板资产下载域名的任务异常".to_string())? + .map_err(|error| format!("解析画板资产下载域名失败:{error}"))?; + if addresses.is_empty() { + return Err("画板资产下载域名没有可用地址".to_string()); + } + if addresses + .iter() + .any(|address| external_asset_host_is_private(&address.ip().to_string())) + { + return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string()); + } + builder = builder.resolve_to_addrs(host, &addresses); + } + builder + .build() + .map_err(|error| format!("创建画板资产安全下载客户端失败:{error}")) +} + +pub(crate) async fn resolve_canvas_resource_download_with_limit( + _client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + resource: &serde_json::Value, + max_bytes: usize, +) -> Result, String> { + if max_bytes == 0 { + return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string()); + } + let secure_client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))?; let object_key = json_string_field(resource, "objectKey"); let image_src = json_string_field(resource, "imageSrc"); let source_hint = object_key.as_deref().or(image_src.as_deref()); - let signed_url = if let Some(object_key) = object_key.as_deref() { + let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() { let read_url = format!( "{}/api/external/v1/assets/read-url?objectKey={}", api_base_url, percent_encode_query_component(object_key) ); - Some(resolve_external_asset_signed_url(client, api_key, read_url).await?) + ( + Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?), + true, + ) } else if let Some(image_src) = image_src.as_deref() { if image_src.starts_with('/') { let read_url = format!( @@ -469,32 +610,43 @@ pub(crate) async fn resolve_canvas_resource_download( api_base_url, percent_encode_query_component(image_src) ); - Some(resolve_external_asset_signed_url(client, api_key, read_url).await?) + ( + Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?), + true, + ) } else if image_src.starts_with("http://") || image_src.starts_with("https://") { - Some(image_src.to_string()) + (Some(image_src.to_string()), false) } else { - None + (None, false) } } else { - None + (None, false) }; let Some(url) = signed_url else { return Ok(None); }; - let response = client + let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?; + let download_client = build_external_asset_download_client(&url, api_base_url).await?; + let mut response = download_client .get(url) .send() .await .map_err(|error| format!("下载画板资产失败:{error}"))?; let status = response.status(); + if status.is_redirection() { + return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); + } if !status.is_success() { return Err(format!("下载画板资产失败:HTTP {}", status.as_u16())); } if response .content_length() - .is_some_and(|size| size > 20 * 1024 * 1024) + .is_some_and(|size| size > max_bytes as u64) { - return Err("画板资产超过 20 MiB,已拒绝同步".to_string()); + return Err(format!( + "画板资产超过当前 {} 字节下载预算,已拒绝同步", + max_bytes + )); } let media_type = response .headers() @@ -504,26 +656,40 @@ pub(crate) async fn resolve_canvas_resource_download( .filter(|value| !value.is_empty()) .unwrap_or("application/octet-stream") .to_string(); - let bytes = response - .bytes() + let mut bytes = Vec::with_capacity( + response + .content_length() + .and_then(|size| usize::try_from(size).ok()) + .unwrap_or_default() + .min(max_bytes), + ); + while let Some(chunk) = response + .chunk() .await - .map_err(|error| format!("读取画板资产失败:{error}"))?; - if bytes.len() > 20 * 1024 * 1024 { - return Err("画板资产超过 20 MiB,已拒绝同步".to_string()); + .map_err(|error| format!("读取画板资产失败:{error}"))? + { + let next_len = bytes + .len() + .checked_add(chunk.len()) + .ok_or_else(|| "画板资产下载大小溢出".to_string())?; + if next_len > max_bytes { + return Err(format!( + "画板资产超过当前 {} 字节下载预算,已拒绝同步", + max_bytes + )); + } + bytes.extend_from_slice(&chunk); } validate_canvas_downloaded_asset_content( source_hint, image_src.is_some(), &media_type, - &bytes, + bytes.as_slice(), )?; if bytes.is_empty() { return Ok(None); } - Ok(Some(CanvasResourceDownload { - bytes: bytes.to_vec(), - media_type, - })) + Ok(Some(CanvasResourceDownload { bytes, media_type })) } pub(crate) async fn resolve_external_asset_signed_url( @@ -890,6 +1056,22 @@ pub(crate) fn register_local_asset_entry( #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + + fn read_asset_test_request(stream: &mut std::net::TcpStream) { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set asset test read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).expect("read asset test request"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + } + } #[test] fn canvas_download_accepts_supported_image_magic() { @@ -971,4 +1153,109 @@ mod tests { ) .expect("video downloads are outside image magic validation"); } + + #[test] + fn canvas_download_url_blocks_private_direct_sources_but_allows_configured_resign_origin() { + for url in [ + "http://127.0.0.1/internal.png", + "http://169.254.169.254/latest/meta-data", + "http://[::1]/internal.png", + "http://localhost/internal.png", + ] { + assert!( + validate_external_asset_download_url(url, "http://127.0.0.1:3101", false,).is_err() + ); + } + validate_external_asset_download_url( + "http://127.0.0.1:3101/api/assets/object/stable.png", + "http://127.0.0.1:3101", + true, + ) + .expect("configured External Editor origin may serve a resigned stable object"); + validate_external_asset_download_url( + "https://cdn.example.test/assets/stable.png", + "http://127.0.0.1:3101", + false, + ) + .expect("public HTTPS asset is allowed"); + } + + #[tokio::test] + async fn canvas_download_rejects_redirects_before_following_private_targets() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind redirect download fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let signed_url = format!("{base_url}/signed.png"); + let server = std::thread::spawn(move || { + let (mut signing, _) = listener.accept().expect("accept signing request"); + read_asset_test_request(&mut signing); + let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string(); + write!( + signing, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write signing response"); + let (mut download, _) = listener.accept().expect("accept asset request"); + read_asset_test_request(&mut download); + write!( + download, + "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .expect("write redirect response"); + }); + + let error = resolve_canvas_resource_download( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &serde_json::json!({"objectKey": "stable/slice.png"}), + ) + .await + .err() + .expect("redirect must fail closed"); + server.join().expect("join redirect fixture"); + assert!(error.contains("重定向")); + } + + #[tokio::test] + async fn canvas_download_applies_remaining_budget_before_buffering_body() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind bounded download fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let signed_url = format!("{base_url}/signed.png"); + let server = std::thread::spawn(move || { + let (mut signing, _) = listener.accept().expect("accept signing request"); + read_asset_test_request(&mut signing); + let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string(); + write!( + signing, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write signing response"); + let (mut download, _) = listener.accept().expect("accept bounded asset request"); + read_asset_test_request(&mut download); + write!( + download, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 9\r\nConnection: close\r\n\r\n" + ) + .expect("write oversized response headers"); + }); + + let error = resolve_canvas_resource_download_with_limit( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &serde_json::json!({"objectKey": "stable/slice.png"}), + 8, + ) + .await + .err() + .expect("content length over remaining budget must fail before buffering"); + server.join().expect("join bounded fixture"); + assert!(error.contains("下载预算")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs index 73f8cb391..c9f4e7ea9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs @@ -1,6 +1,7 @@ use std::time::Duration; use chromiumoxide::Page; +use serde::Deserialize; use tokio::time::Instant; use super::{ @@ -20,6 +21,124 @@ pub(in crate::browser) const GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP: Duration = pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES: usize = 8; pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12; +pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = + "primary-action=synchronous-click-sequence-advance\nrestart=synchronous-click-sequence-advance"; + +const GENERIC_ACTION_SEQUENCE_PROBE_KEY: &str = "__genarrativeGenericActionSequenceProbe"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GenericActionSequenceProbe { + status: String, + before_sequence: Option, + after_sequence: Option, +} + +fn generic_action_sequence_probe_script(selector: &str) -> Result { + let selector = serde_json::to_string(selector) + .map_err(|_| "固定试玩动作因果探针 selector 无法编码".to_string())?; + let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY) + .map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?; + Ok(format!( + r#"(() => {{ + const key = {key}; + const controls = document.querySelectorAll({selector}); + if (controls.length !== 1 || !(controls[0] instanceof HTMLElement)) {{ + globalThis[key] = {{ status: 'invalid-control', beforeSequence: null, afterSequence: null }}; + return 'invalid-control'; + }} + const control = controls[0]; + const readSequence = () => {{ + const surface = document.querySelectorAll('script#playable-web-game-state'); + if (surface.length !== 1 || surface[0].getAttribute('type') !== 'application/json') return null; + try {{ + const value = JSON.parse(String(surface[0].textContent || '')); + return Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null; + }} catch (_) {{ + return null; + }} + }}; + globalThis[key] = {{ status: 'armed', beforeSequence: null, afterSequence: null }}; + control.addEventListener('click', () => {{ + const beforeSequence = readSequence(); + globalThis[key] = {{ status: 'captured', beforeSequence, afterSequence: null }}; + queueMicrotask(() => {{ + const afterSequence = readSequence(); + globalThis[key] = {{ status: 'completed', beforeSequence, afterSequence }}; + }}); + }}, {{ capture: true, once: true }}); + return 'armed'; +}})()"# + )) +} + +async fn arm_generic_action_sequence_probe( + page: &Page, + selector: &'static str, + action: &'static str, + deadline: Instant, +) -> Result<(), String> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?; + let script = generic_action_sequence_probe_script(selector)?; + let evaluated = tokio::time::timeout(remaining, page.evaluate(script)) + .await + .map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))? + .map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?; + let status = evaluated + .into_value::() + .map_err(|_| format!("固定试玩动作 {action} 因果探针安装结果无效"))?; + if status != "armed" { + return Err(format!("固定试玩动作 {action} 因果探针无法绑定唯一控件")); + } + Ok(()) +} + +fn validate_generic_action_sequence_probe( + action: &str, + probe: &GenericActionSequenceProbe, +) -> Result<(), String> { + if probe.status != "completed" { + return Err(format!( + "generic-v1 {action} 未形成同步动作因果证据:status={}", + probe.status + )); + } + let before = probe + .before_sequence + .ok_or_else(|| format!("generic-v1 {action} 点击前 sequence 无效"))?; + let after = probe + .after_sequence + .ok_or_else(|| format!("generic-v1 {action} 点击后 sequence 无效"))?; + if after <= before { + return Err(format!( + "generic-v1 {action} 自身未推进 sequence:before={before}, after={after};不得用 RAF/timer 自增冒充动作结果" + )); + } + Ok(()) +} + +async fn verify_generic_action_sequence_probe( + page: &Page, + action: &'static str, + deadline: Instant, +) -> Result<(), String> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?; + let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY) + .map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?; + let script = format!("globalThis[{key}] || null"); + let evaluated = tokio::time::timeout(remaining, page.evaluate(script)) + .await + .map_err(|_| format!("固定试玩动作 {action} 因果证据读取超时"))? + .map_err(|_| format!("固定试玩动作 {action} 因果证据读取失败"))?; + let probe = evaluated + .into_value::() + .map_err(|_| format!("固定试玩动作 {action} 因果证据无效"))?; + validate_generic_action_sequence_probe(action, &probe) +} pub(in crate::browser) fn generic_start_phase_is_valid(state: &PlayableWebGameState) -> bool { state.phase == BrowserPlaytestPhase::Playing @@ -332,6 +451,13 @@ async fn execute_generic_primary_action_attempt( generic_start_phase_is_valid, )?; + arm_generic_action_sequence_probe( + page, + PLAYTEST_PRIMARY_ACTION_SELECTOR, + "primary-action", + deadline, + ) + .await?; click_playtest_control( page, PLAYTEST_PRIMARY_ACTION_SELECTOR, @@ -339,6 +465,7 @@ async fn execute_generic_primary_action_attempt( deadline, ) .await?; + verify_generic_action_sequence_probe(page, "primary-action", deadline).await?; if record_contract_assertions { result.set_assertion("primary-action-control-clicked", true); } @@ -454,7 +581,9 @@ pub(super) async fn execute_generic_playtest( ) .await?; + arm_generic_action_sequence_probe(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; + verify_generic_action_sequence_probe(page, "restart", deadline).await?; result.set_assertion("restart-control-clicked", true); let restarted = poll_playable_web_game_state( page, @@ -557,3 +686,28 @@ pub(super) async fn execute_generic_playtest( result.set_assertion("non-loss-progression-observed", true); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_sequence_probe_rejects_timer_only_progress() { + let timer_only = GenericActionSequenceProbe { + status: "completed".to_string(), + before_sequence: Some(9), + after_sequence: Some(9), + }; + let error = validate_generic_action_sequence_probe("primary-action", &timer_only) + .expect_err("a later timer tick must not count as click-driven sequence progress"); + assert!(error.contains("RAF/timer")); + + let click_driven = GenericActionSequenceProbe { + status: "completed".to_string(), + before_sequence: Some(9), + after_sequence: Some(10), + }; + validate_generic_action_sequence_probe("restart", &click_driven) + .expect("a synchronous click-driven increment is valid"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 03a81c1e1..cb501dba6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -17,9 +17,9 @@ pub(super) use generic::{ finish_generic_stability_observation, generic_non_loss_progression_phase_is_valid, generic_primary_action_phase_is_valid, generic_restart_phase_is_valid, generic_start_phase_is_valid, validate_generic_stability_sample, - GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, - GENERIC_PLAYTEST_POST_ACTION_WINDOW, GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, - GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW, + GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, + GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_POST_ACTION_WINDOW, + GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW, GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, }; @@ -270,6 +270,10 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce update_playtest_fingerprint_component(&mut hasher, PROBE_PLAYTEST_CONTROL_SCRIPT); match scenario { BrowserPlaytestScenario::GenericV1 => { + update_playtest_fingerprint_component( + &mut hasher, + GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, + ); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_PRIMARY_ACTION_SELECTOR); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index bdfe26247..da36bdb44 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -258,7 +258,7 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { assert_eq!( generic, - "c3af84c2501755935eb30c1eed91bc6ef2a4cb2057224f4d3a4a100aef1a2908" + "8b7b6525e6fdd2517ad25da1a06644b96e57e744fcacdd70bbab35b09d64adb0" ); assert_eq!( lane, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 5936c6b22..65a2a05d4 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -1329,10 +1329,14 @@ export function projectSupervisorCollaboratingAgentRuntimes( } const runtimesByAgentId = new Map(); for (const runtime of Object.values(runtimeByAgentId)) { + const isVisibleChildSource = + ['agent-delegate', 'agent-delegate-retry'].includes(runtime?.source ?? '') || + (supervisorRuntime.source === 'project-supervisor-game-chat' && + runtime?.source === 'agent-ready-task-scheduler'); if ( !runtime || runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID || - !['agent-delegate', 'agent-delegate-retry'].includes(runtime.source) || + !isVisibleChildSource || runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || runtime.parentRunId !== supervisorRuntime.runId ) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 5d610146f..04cf173e2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2665,6 +2665,7 @@ export function registerProjectSupervisorSurfaceTests() { }); const supervisor = gameChatRuntimeState({ runId: 'active-parent-run', + source: 'project-supervisor-game-chat', recentEvents: [ gameChatRuntimeEvent({ runId: 'active-parent-run', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3cf731fa0..6c0f4a4dd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5924,7 +5924,7 @@ - 事故事实:`game01` 的首个根 run 已交付并验证水晶下落方块原型,但父 run 在上下文压缩后因 token 上限失败;用户随后输入“继续”时,客户端创建了 task 仅为“继续”的新 game-chat root,完成合同重置 seed manifest,五分钟 fallback 再把该短语当主题整文件覆盖 `game/index.html`。这不是模型随机换题,而是 root 目标未继承、每新 run 无条件 reset 和 fallback 无条件 `file.write` 叠加形成的确定性缺陷。 - 续跑决策:同一 Supervisor Session、同一持久 source 的最近失败根 run 后,严格继续短语创建 successor root,并继承前序原始任务、baseline revision / artifact 身份;纯继续识别统一由一个精确函数负责,覆盖“继续 / 接着 / continue / go on”等无新约束短语。真正新需求、跨 Session、跨 GUI / CLI / game-chat source、前序正常完成或含具体新约束的输入继续创建独立新任务并重置本轮 manifest。successor 不复用旧网络请求、pending、action、Provider lifecycle 或 sidecar,只继承业务目标和已提交项目事实。 - 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。 -- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四,全部切片累计下载最多 `32 MiB`;主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。 +- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同;generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码并复核清单内容摘要、规范像素摘要、可见 alpha 和四类唯一性。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 @@ -5948,7 +5948,7 @@ ## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 - 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。 -- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 仍受父 run 五分钟总截止约束,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 +- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 - 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。 - 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 - MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index d2668b036..86f665e16 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -87,7 +87,7 @@ game-chat 素材完整快车道采用七任务口径。父 Run 与全部 child R 失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。 -game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 +game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 七任务 lane 后终态时,必须等到七个首版任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 ```bash npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5d0f74c55..46e172477 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -64,13 +64,13 @@ - 时间预算:从 game-chat 父 Run 接受用户请求开始,素材完整首版使用 `4200` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `4500` 秒绝对硬上限。该值来自现有两次串行生成各自最长 35 分钟的客户端等待合同,并为代码兜底、静态检查和双视口试玩保留 5 分钟。整个 Runtime pass 受同一 `timeout_at` 约束;硬上限内未通过完成门必须失败关闭,不得为了守时跳过图集、换普通生图或回退纯代码核心画面。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`。 - Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性规范图生成并行;规范图和设计方向就绪后,Runtime 确定性派发 `art-asset-plan` 的 icon-spritesheet 生成。图集登记后 `code-prototype` 最多执行一次 Provider 首版写入请求;软预算耗尽时只允许生成真实加载并裁切图集的确定性本地兜底。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`。 - 可玩兜底:软预算或首版 Provider 无法及时完成时,只能为已显式实现真实语义的玩法生成完整、自包含、无远程运行依赖的中文 HTML 模板;未知玩法失败关闭,不能只替换标题后套用固定收集游戏。俄罗斯方块模板必须包含 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败;收集模板只匹配明确收集类目标。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 只随真实输入、状态迁移或模拟状态变化推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,不得通过固定失败冒充试玩通过,也不得由纯渲染帧空转 `sequence`。兜底只允许写入缺失或精确初始化占位的 `game/index.html`;存在非占位入口时,当前 `code-prototype` 必须读取并实际 patch,取得本人 `mutationRevision` 后再静态检查和试玩,不得反复用只读 smoke 冒充续作。 -- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片。全部切片下载累计最多 `32 MiB`。`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。 +- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,四个切片均须保留与整图完全一致的 `sourceResourceId`,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。四类唯一性按尺寸与解码后的规范 RGBA 像素摘要判断,不能用不同 PNG 压缩或 ancillary chunk 冒充不同素材。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片;进程在固定主图落盘后崩溃时,保留的 generation 账本只允许按远端预期摘要幂等补齐同一组合同,路径内容冲突继续失败关闭。全部切片下载累计最多 `32 MiB`。完成门重新读取本地素材时仍执行相同文件大小、解码内存、内容摘要、规范像素摘要、可见 alpha 与四类唯一性校验。`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。 - 关联验收:快车道必须分别验证三 Director 首波并行、`art-asset-plan` 在代码前完成、`x/7` 投影、七个专业 Agent 安全 final-reply、4200 / 4500 秒累计预算、规范图到 icon-spritesheet 的真实引用、`iconImageSrcs` 本地持久化与资源 ID 绑定、失败续跑目标继承、非占位入口禁止整文件覆盖、纯代码核心画面、猜测单个 atlas 裁切与整图展示失败、四类独立切片可见使用通过、action-driven `sequence`,以及当前 revision 的静态 smoke 与浏览器试玩。 ## 2026-08-03 game-chat 开发态同源与持久输出修复 - 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 -- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后 `code-prototype → preview-readiness → preview-playtest` 的六任务 lane、平台 `art-spec.png` 美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。 +- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后按 `art-asset-plan → code-prototype → preview-readiness → preview-playtest` 推进七任务 lane、规范图派生图集美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。 - source-aware lane 的首波 ready child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source 的种子 lane 解析全部零依赖任务,不得硬编码某个 Agent;当前 game-chat 首波是 `design-director / art-director / code-director`,后续 code prototype / preview child 仍严格拒绝 `Pending` 收束。 - 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream`。`streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。 diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index b2b3bdcc9..73097118e 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1276,8 +1276,12 @@ fn compact_editor_generation_result(mut result: Value) -> Value { let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else { continue; }; - resource - .retain(|key, _| matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId")); + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" + ) + }); } if let Some(icon_image_srcs) = object .get_mut("iconImageSrcs") @@ -1289,7 +1293,10 @@ fn compact_editor_generation_result(mut result: Value) -> Value { }; if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { resource.retain(|key, _| { - matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId") + matches!( + key.as_str(), + "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" + ) }); } icon.retain(|key, _| { @@ -1417,6 +1424,7 @@ fn compact_external_generation_resource(resource: &mut serde_json::Map