diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index 68b49e565..f50343523 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -10,7 +10,7 @@ "agc_write_file.parameters.path": "当前项目根下的相对路径,例如 game/index.html、assets/manifest.json 或 data/gameplay-spec.md", "agc_write_file.parameters.content": "仅填写目标文件的完整原始 UTF-8 正文", "taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。", - "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求", + "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求:写明主题、风格、玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效等具体需要的素材。工具会补齐沿用规范图和素材独立排布的通用要求;内容类别不代表切片数量或返回顺序", "taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate", "agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。", "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json index bb96a7205..988c2c318 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json @@ -15,8 +15,8 @@ "scene_constraints": "必须是可见的真实图片产物,适合作为 Canvas 或 HTML 游戏场景底图;不得做成素材图集、完整游戏截图、海报或概念板;必须原创,不得复刻现有游戏场景、Logo、贴图、标志性布局或受保护视觉语言", "spritesheet_subject": "{};使用项目原创命名和原创阵营设计", "spritesheet_style": "清晰可切分的原创 Web 游戏素材图集;严格沿用当前规范图的轮廓、材质、色板与光照,素材类别以当前玩法合同为准", - "spritesheet_composition": "{} 游戏素材图集,按玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效分区,留出清楚切分间距", - "spritesheet_constraints": "生成可直接用于游戏的真实透明图片,素材仅包含当前项目玩法合同需要的实体和反馈;角色轮廓、图标排布与配色采用项目原创设计", + "spritesheet_composition": "{} 游戏素材图集,覆盖当前玩法需要的玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效;每类可含多个素材,各素材独立排布并留出清楚切分间距", + "spritesheet_constraints": "素材仅包含当前项目玩法合同需要的实体和反馈,可独立用于游戏;角色轮廓、图标排布与配色采用项目原创设计", "image_generation": "根据用户需求生成一张全新的原创图片。主体、环境、风格、构图、光线和色彩以用户描述为准;不要生成素材图集、规范展板、完整游戏截图或文字说明。不得修改或复述为已有图片编辑。\n\n用户需求:{}", "character_generation": "根据用户需求生成一张全新的原创角色形象或人物立绘。清楚表现角色外貌、服饰、姿势、表情、画风、构图和背景;只生成一张完整图片,不要生成图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", "publication_generation": "根据用户需求生成一张全新的原创游戏发布宣传图。突出主体、卖点、氛围、构图、色彩和适合发布展示的画面层次;只生成一张完整图片,不要生成素材图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 7457a5a0b..d8e594703 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.40", + "version": "2026-08-26.41", "skills": [ { "name": "agc-unity-editor", @@ -101,7 +101,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "dcf243784b7279fc4819140d746e607bde840a53bc419172d615c53364a2ecb4" + "sha256": "d38571ffb5b12b40f23d04b76edbb06cbda06a11590d4dd0f784b524bf30071c" }, { "name": "agc-web-game-development", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index 004b18128..9f7aabaf9 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -35,6 +35,13 @@ required; choose it from the brief: Pass `sliceMode` only for `kind="icon-spritesheet"`, and `gridX`/`gridY` only for `sliceMode="grid"`. Read the selected mode from the returned result. +For `taonier_prepare_game_art`, provide `brief` and optionally `mode`. Name the +game's theme, style, and needed player states, targets or collectibles, +obstacles or scene elements, and feedback effects in `brief`. The tool adds +shared requirements for visual-spec consistency and separated asset placement. +These content categories do not specify a slice count or returned ordering; +inspect the actual images before deciding how to use them. + ## Authorization boundary If the tool returns `401` or `403`, stop the operation and report that login or permission state needs attention. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md index e305c6b32..974e52f25 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -2,6 +2,8 @@ `agc_generate_image` and `taonier_prepare_game_art` are the reviewed paid art entries exposed to the AGC Codex thread. The former covers one ordinary/character/spec/UI/publication image; `agc_edit_image` covers edits to an existing registered image; the latter covers the complete game-art package and canonical slices. Call these tools only through the approved AGC session. +- For a package, describe the concrete theme, style, entities, states, and effects in `brief`. The tool includes the package's shared visual-spec and placement requirements in generation. No additional tool fields are required. Ordinary `agc_generate_image` icon descriptions remain the supplied text without package requirements. + - `mode="reuse-or-create"` reuses a complete trusted package and creates only missing assets. It is the safe default for existing games. - `mode="regenerate"` is reserved for an explicit user request to replace or restyle the package. Use it even when a complete package exists, but never bypass an unresolved paid operation. - `mode="regenerate"` requires a trusted, decodable, registered `art-spec.png` and background. An old spritesheet or canonical slices may be absent. If either the spec or background is missing or invalid, use `mode="reuse-or-create"`. 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 245491074..fb40540ec 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 @@ -28,6 +28,12 @@ const EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-res const EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX: &str = "platform-generation-source-preserved-no-retry:"; +#[derive(Clone, Copy, PartialEq, Eq)] +enum PlatformArtGenerationPurpose { + Ordinary, + GameArtPackage, +} + /// 设计阶段的功能页面输出路径是否合法:必须落在 `assets/ui-pages/` 下、`.png` 结尾, /// 文件名只含安全字符且不超过 80 字符。 pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool { @@ -2297,6 +2303,45 @@ pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), Stri Ok(()) } +fn validate_platform_art_icon_descriptions(descriptions: &[String]) -> Result<(), String> { + if descriptions.is_empty() || descriptions.len() > 100 { + return Err("图标素材描述数量必须在 1 到 100 条之间".to_string()); + } + for description in descriptions { + validate_platform_art_icon_prompt(description)?; + } + let joined = descriptions + .iter() + .map(|description| description.trim()) + .collect::>() + .join("\n"); + if joined.chars().count() > 2_000 || joined.len() > 6 * 1024 { + return Err("图标素材描述合计不能超过 2000 个字符或 6144 个 UTF-8 字节".to_string()); + } + Ok(()) +} + +fn platform_art_spritesheet_descriptions( + prompt: &str, + options: &PlatformArtAssetGenerationOptions, + purpose: PlatformArtGenerationPurpose, +) -> Result, String> { + let mut descriptions = vec![prompt.trim().to_string()]; + if purpose == PlatformArtGenerationPurpose::GameArtPackage { + // 与描述性 artSpec 复用同一份文案,但只有正式输入会进入服务端提示词。 + descriptions.extend([ + prompt_text!("media.spritesheet_style").to_string(), + format!( + prompt_text!("media.spritesheet_composition"), + options.aspect_ratio + ), + prompt_text!("media.spritesheet_constraints").to_string(), + ]); + } + validate_platform_art_icon_descriptions(&descriptions)?; + Ok(descriptions) +} + fn decode_platform_art_image_with_limits( download: &CanvasResourceDownload, label: &str, @@ -2638,7 +2683,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at( false, &context, Some(admission.guard), - false, + PlatformArtGenerationPurpose::Ordinary, ) .await } @@ -2819,7 +2864,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a false, runtime_context, None, - false, + PlatformArtGenerationPurpose::Ordinary, ) .await } @@ -2843,7 +2888,7 @@ pub(in crate::agent) async fn generate_art_package_asset_with_runtime_options_at retain_runtime_state, runtime_context, None, - true, + PlatformArtGenerationPurpose::GameArtPackage, ) .await } @@ -2993,7 +3038,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, admitted_guard: Option, - normalize_package_png: bool, + purpose: PlatformArtGenerationPurpose, ) -> Result { if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); @@ -3063,15 +3108,30 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( acquire_project_write_lock_with_wait(root, "canvas.asset_generate.runtime.recover")?; recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; } - let mut prepared = request_platform_art_asset_with_runtime_options_at( - root, - prompt, - briefs, - options, - Some(runtime_context), - ) - .await?; - if normalize_package_png { + let mut prepared = match purpose { + PlatformArtGenerationPurpose::Ordinary => { + request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + Some(runtime_context), + ) + .await? + } + PlatformArtGenerationPurpose::GameArtPackage => { + request_platform_art_asset_for_purpose_at( + root, + prompt, + briefs, + options, + Some(runtime_context), + purpose, + ) + .await? + } + }; + if purpose == PlatformArtGenerationPurpose::GameArtPackage { normalize_art_package_png(&mut prepared.download)?; prepared.extension = "png".to_string(); if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet @@ -3121,6 +3181,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, +) -> Result { + request_platform_art_asset_for_purpose_at( + root, + prompt, + briefs, + options, + runtime_context, + PlatformArtGenerationPurpose::Ordinary, + ) + .await +} + +async fn request_platform_art_asset_for_purpose_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, + runtime_context: Option<&PlatformArtGenerationRuntimeContext>, + purpose: PlatformArtGenerationPurpose, ) -> Result { if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { validate_platform_art_icon_prompt(prompt)?; @@ -3202,12 +3281,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &context.run_id, )?; } - return Box::pin(request_platform_art_asset_with_runtime_options_at( + return Box::pin(request_platform_art_asset_for_purpose_at( root, prompt, briefs, options, runtime_context, + purpose, )) .await; } @@ -3253,12 +3333,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &context.run_id, )?; } - return Box::pin(request_platform_art_asset_with_runtime_options_at( + return Box::pin(request_platform_art_asset_for_purpose_at( root, prompt, briefs, options, runtime_context, + purpose, )) .await; } @@ -3336,6 +3417,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at snapshot.generation_prompt, ) } else { + // 只为新请求组装美术包要求;旧账本继续使用原始请求体、幂等键和 brief 意图。 + let icon_descriptions = (options.asset_kind == GameCreationAppAssetKind::IconSpritesheet) + .then(|| platform_art_spritesheet_descriptions(&generation_prompt, options, purpose)) + .transpose()?; let canvas_context = prepare_external_canvas_generation_context(root, &client, &binding_access).await?; let generation_kind = match options.asset_kind { @@ -3368,7 +3453,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "/api/external/v1/editor/icon-spritesheets/generations", serde_json::json!({ "referenceId": reference_id, - "iconDescriptions": [generation_prompt], + "iconDescriptions": icon_descriptions, "sliceCount": options.slice_count, "sliceMode": options.slice_mode, "gridX": options.grid_x, @@ -13003,6 +13088,334 @@ mod canvas_generation_tests { .expect("BOM is not whitespace under the API Server Rust trim contract"); } + #[test] + fn art_package_descriptions_keep_the_entire_brief_within_api_limits() { + let options = PlatformArtAssetGenerationOptions { + asset_kind: GameCreationAppAssetKind::IconSpritesheet, + aspect_ratio: "16:9".into(), + ..PlatformArtAssetGenerationOptions::default() + }; + let brief = "🪙".repeat(200); + let descriptions = platform_art_spritesheet_descriptions( + &format!(" {brief}\n"), + &options, + PlatformArtGenerationPurpose::GameArtPackage, + ) + .unwrap(); + assert_eq!(descriptions[0], brief); + let metadata = platform_art_asset_art_spec(&options); + for field in ["style", "composition", "constraints"] { + assert!(descriptions + .iter() + .any(|text| Some(text.as_str()) == metadata[field].as_str())); + } + assert_eq!( + platform_art_spritesheet_descriptions( + &brief, + &options, + PlatformArtGenerationPurpose::Ordinary, + ) + .unwrap(), + vec![brief.clone()] + ); + assert!(platform_art_spritesheet_descriptions( + &(brief + "字"), + &options, + PlatformArtGenerationPurpose::GameArtPackage, + ) + .is_err()); + } + + #[test] + fn art_package_description_limits_include_separators_and_utf8_bytes() { + assert!(validate_platform_art_icon_descriptions(&[]).is_err()); + assert!(validate_platform_art_icon_descriptions(&[" ".into()]).is_err()); + assert!(validate_platform_art_icon_descriptions(&vec!["字".into(); 100]).is_ok()); + assert!(validate_platform_art_icon_descriptions(&vec!["字".into(); 101]).is_err()); + let mut chars = vec!["字".repeat(200); 9]; + chars.push("字".repeat(191)); + assert_eq!(chars.join("\n").chars().count(), 2000); + validate_platform_art_icon_descriptions(&chars).unwrap(); + chars.last_mut().unwrap().push('字'); + assert!(validate_platform_art_icon_descriptions(&chars).is_err()); + let mut bytes = vec!["🪙".repeat(200); 7]; + bytes.push("🪙".repeat(134) + "a"); + assert_eq!(bytes.join("\n").len(), 6144); + validate_platform_art_icon_descriptions(&bytes).unwrap(); + bytes.last_mut().unwrap().push('b'); + assert!(validate_platform_art_icon_descriptions(&bytes).is_err()); + } + + #[tokio::test] + async fn art_package_spritesheet_posts_requirements_but_ordinary_icons_keep_original_text() { + assert_art_package_spritesheet_http_request( + PlatformArtGenerationPurpose::GameArtPackage, + None, + ) + .await; + assert_art_package_spritesheet_http_request(PlatformArtGenerationPurpose::Ordinary, None) + .await; + } + + #[tokio::test] + async fn art_package_spritesheet_recovers_old_requests_without_injecting_new_requirements() { + for status in ["prepared", "accepted", "legacy-completed"] { + assert_art_package_spritesheet_http_request( + PlatformArtGenerationPurpose::GameArtPackage, + Some(status), + ) + .await; + } + } + + async fn assert_art_package_spritesheet_http_request( + purpose: PlatformArtGenerationPurpose, + old_status: Option<&str>, + ) { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + init_local_game_project_at(root, "art-package-request", "美术包请求测试").unwrap(); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let api_key = "art-package-fixture-key"; + install_test_external_project_binding(root, &base_url, api_key); + install_test_canonical_spec_binding(root, &base_url, api_key); + let result = serde_json::json!({ + "spritesheetResource": { + "resourceId": "art-package-sheet", "assetObjectId": "art-package-object", + "projectId": "manual-test-canvas", "taskId": "art-package-task", + "objectKey": "art-package.png", + }, + "sliceMode": "connected-components", "iconImageSrcs": [], + "sliceWarning": {"reason": "夹具只提供图集"} + }); + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/art-spritesheet.png".into()), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, + asset_label: "森林小游戏素材".into(), + aspect_ratio: "1:1".into(), + image_size: "1K".into(), + slice_mode: Some("connected-components".into()), + ..PlatformArtAssetGenerationOptions::default() + }; + let context = PlatformArtGenerationRuntimeContext { + agent_id: "direct-codex-art".into(), + task_id: "direct-codex-art-art-spritesheet".into(), + session_id: "art-package-session".into(), + run_id: "art-spritesheet".into(), + source: "direct-codex".into(), + action_id: "direct-taonier-art-spritesheet".into(), + action_fingerprint: "direct-taonier-art-v1:icon-spritesheet:art-spritesheet".into(), + }; + let brief = "狐狸奔跑与受击状态\n橡果、树桩、石块和收集反馈"; + let frozen = + old_status.map(|status| { + let access = + ExternalEditorBindingAccess::for_developer(&base_url, api_key).unwrap(); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, &context, "/api/external/v1/editor/icon-spritesheets/generations", + "旧版美术包画布", brief, &serde_json::json!({ + "referenceId": "scene-route-art-spec-resource", "iconDescriptions": [brief], + "projectId": "manual-test-canvas", "assetFolderId": "manual-test-assets", + "sliceMode": "connected-components", "aspectRatio": "1:1", "imageSize": "1K", + }), &access, + ).unwrap(); + let frozen = ( + platform_art_generation_runtime_request_body_json(&state).to_string(), + platform_art_generation_runtime_idempotency_key(&state).to_string(), + ); + match status { + "accepted" => { + mark_platform_art_generation_runtime_accepted( + root, + state, + "art-package-operation", + 0, + ) + .unwrap(); + } + "legacy-completed" => { + mark_platform_art_generation_runtime_legacy_completed(root, state, &result) + .unwrap(); + } + "prepared" => {} + _ => unreachable!(), + } + frozen + }); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server_url = base_url.clone(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + if stop_receiver.try_recv().is_ok() || std::time::Instant::now() >= deadline { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept art package request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender.send(request.clone()).unwrap(); + if request + .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + { + write_test_json_response( + &mut stream, + "202 Accepted", + &serde_json::json!({"data": { + "operationId": "art-package-operation", "status": "queued", "pollAfterMs": 0, + }}), + ); + } else if request + .starts_with("GET /api/external/v1/generations/art-package-operation ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": { + "operationId": "art-package-operation", "status": "completed", "pollAfterMs": 0, + "result": result, + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "read": {"signedUrl": format!("{server_url}/art-package.png")} + }), + ); + } else if request.starts_with("GET /art-package.png ") { + let png = rgba_test_png(128).bytes; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", png.len()).unwrap(); + stream.write_all(&png).unwrap(); + } else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": [{ + "projectId": "manual-test-canvas", "title": "美术包画布" + }]}}), + ); + } else if request + .starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": { + "folderId": "manual-test-assets", "label": "美术包素材" + }}}), + ); + } else { + write_test_json_response( + &mut stream, + "400 Bad Request", + &serde_json::json!({"error": "unexpected fixture request"}), + ); + } + } + }); + let generated = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test(base_url, api_key.into()), + async { + if purpose == PlatformArtGenerationPurpose::GameArtPackage { + let first = generate_art_package_asset_with_runtime_options_at( + root, + brief, + &[], + &options, + false, + true, + &context, + ) + .await?; + let replayed = generate_art_package_asset_with_runtime_options_at( + root, + brief, + &[], + &options, + false, + true, + &context, + ) + .await?; + assert_eq!(first.asset.id, replayed.asset.id); + Ok(first) + } else { + generate_platform_art_asset_with_runtime_options_at( + root, + brief, + &[], + &options, + false, + &context, + ) + .await + } + }, + ) + .await; + stop_sender.send(()).unwrap(); + server.join().unwrap(); + if let Err(error) = generated { + panic!("art package request failed: {error}"); + } + let requests = request_receiver.try_iter().collect::>(); + let posts = requests + .iter() + .filter(|request| request.starts_with("POST ")) + .collect::>(); + let expected_posts = + usize::from(!matches!(old_status, Some("accepted" | "legacy-completed"))); + assert_eq!(posts.len(), expected_posts, "{requests:#?}"); + if let Some(post) = posts.first() { + let body: serde_json::Value = serde_json::from_str(test_request_body(post)).unwrap(); + assert_eq!(body["referenceId"], "scene-route-art-spec-resource"); + assert_eq!(body["aspectRatio"], "1:1"); + assert_eq!(body["imageSize"], "1K"); + if let Some((body, key)) = &frozen { + assert_eq!(test_request_body(post), body); + assert_eq!(test_request_header(post, "idempotency-key"), key); + } else if purpose == PlatformArtGenerationPurpose::Ordinary { + assert_eq!(body["iconDescriptions"], serde_json::json!([brief])); + } else { + let descriptions = body["iconDescriptions"].as_array().unwrap(); + assert_eq!(descriptions[0], brief); + for field in ["style", "composition", "constraints"] { + assert!(descriptions.contains(&body["generationInputs"]["artSpec"][field])); + } + } + } + if let Some((body, key)) = frozen { + let state = read_platform_art_generation_runtime_state(root, &context) + .unwrap() + .unwrap(); + assert_eq!( + platform_art_generation_runtime_request_body_json(&state), + body + ); + assert_eq!(platform_art_generation_runtime_idempotency_key(&state), key); + } + } + #[tokio::test] async fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); @@ -15912,6 +16325,73 @@ mod canvas_generation_tests { ); } + fn install_test_canonical_spec_binding(root: &Path, base_url: &str, api_key: &str) { + // `Scene` 属于「必须带规范图引用」的 kind:先种一张 assets/art-spec.png 并登记成 Canvas 来源。 + fs::write( + root.join(AGENT_RUNTIME_ART_SPEC_PATH), + rgba_test_png(u8::MAX).bytes, + ) + .expect("write canonical art spec"); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + GameCreationAppAssetKind::IconSpec, + "image/png", + "scene-route-test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("manual-test-canvas".to_string()), + resource_id: Some("scene-route-art-spec".to_string()), + asset_object_id: Some("scene-route-art-spec-object".to_string()), + task_id: Some("scene-route-art-spec-task".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register canonical art spec"); + + // 规范图的远端引用走 binding 缓存命中:预置绑定后不需要额外的上传夹具端点。 + { + let access = ExternalEditorBindingAccess::new(base_url, api_key, None) + .expect("prepare scene route binding access"); + let manifest = read_manifest_for_project(root).expect("read scene route manifest"); + let source = manifest + .assets + .iter() + .find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH) + .expect("registered canonical art spec"); + let source_bytes = + fs::read(root.join(&source.local_path)).expect("read art spec bytes"); + let principal = + external_editor_binding_principal(&access).expect("derive scene route principal"); + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&source_bytes)), + &source.media_type, + &source.kind, + ) + .expect("derive scene route source identity"); + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + "manual-test-canvas", + &source_identity, + Some("scene-route-art-spec-resource"), + "generated/scene-route-art-spec.png", + "scene-route-art-spec-object", + Some(1), + Some(1), + unix_timestamp(), + ) + .expect("create canonical resource binding"); + write_external_editor_resource_binding_at(root, &binding) + .expect("persist canonical resource binding"); + } + } + /// 场景(背景)阶段的请求夹具:只接受专用场景路由,记录原始请求,并按队列 → 换签 → 下载链路服务完。 fn serve_scene_route_fixture( stream: &mut std::net::TcpStream, @@ -16034,33 +16514,6 @@ mod canvas_generation_tests { }, ) .expect("allow standalone scene generation"); - // `Scene` 属于「必须带规范图引用」的 kind:先种一张 assets/art-spec.png 并登记成 Canvas 来源。 - fs::write( - root.join(AGENT_RUNTIME_ART_SPEC_PATH), - rgba_test_png(u8::MAX).bytes, - ) - .expect("write canonical art spec"); - register_local_asset_at( - root, - AGENT_RUNTIME_ART_SPEC_PATH, - GameCreationAppAssetKind::IconSpec, - "image/png", - "scene-route-test", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some("manual-test-canvas".to_string()), - resource_id: Some("scene-route-art-spec".to_string()), - asset_object_id: Some("scene-route-art-spec-object".to_string()), - task_id: Some("scene-route-art-spec-task".to_string()), - prompt: None, - model: None, - generation_route: Some("/api/external/v1/editor/images/generations".to_string()), - generation_kind: Some("spec".to_string()), - reference_resource_ids: Vec::new(), - }, - ) - .expect("register canonical art spec"); - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind scene route fixture"); listener @@ -16069,43 +16522,7 @@ mod canvas_generation_tests { let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let api_key = "scene-route-key"; install_test_external_project_binding(root, &base_url, api_key); - // 规范图的远端引用走 binding 缓存命中:预置绑定后不需要额外的上传夹具端点。 - { - let access = ExternalEditorBindingAccess::new(&base_url, api_key, None) - .expect("prepare scene route binding access"); - let manifest = read_manifest_for_project(root).expect("read scene route manifest"); - let source = manifest - .assets - .iter() - .find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH) - .expect("registered canonical art spec"); - let source_bytes = - fs::read(root.join(&source.local_path)).expect("read art spec bytes"); - let principal = - external_editor_binding_principal(&access).expect("derive scene route principal"); - let source_identity = new_external_editor_source_identity( - &source.id, - &format!("{:x}", Sha256::digest(&source_bytes)), - &source.media_type, - &source.kind, - ) - .expect("derive scene route source identity"); - let binding = new_external_editor_resource_binding( - &manifest.project_id, - &principal, - "manual-test-canvas", - &source_identity, - Some("scene-route-art-spec-resource"), - "generated/scene-route-art-spec.png", - "scene-route-art-spec-object", - Some(1), - Some(1), - unix_timestamp(), - ) - .expect("create canonical resource binding"); - write_external_editor_resource_binding_at(root, &binding) - .expect("persist canonical resource binding"); - } + install_test_canonical_spec_binding(root, &base_url, api_key); let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); let others = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); diff --git a/docs/project-memory/plans/【实施计划】AGC美术包生成需求入参修正-2026-10-05.md b/docs/project-memory/plans/【实施计划】AGC美术包生成需求入参修正-2026-10-05.md new file mode 100644 index 000000000..ec58f12bb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC美术包生成需求入参修正-2026-10-05.md @@ -0,0 +1,25 @@ +# AGC 美术包生成需求入参修正实施计划 + +- Version: 1 +- Status: Implemented, Awaiting Acceptance +- Date: 2026-10-05 +- Parent Spec: [里程碑](./【里程碑】AGC美术包生成需求入参修正-2026-10-05.md) + +## 修改顺序 + +1. 在 canvas_generation 的既有美术包专用入口显式传递调用用途,独立组装新图集请求的描述数组;复用 media 提示词来源,保留原 brief 意图和冻结请求路径。 +2. 同步必要的工具说明与 taonier-art-assets skill,更新随包摘要及共享排障记忆。 +3. 用 HTTP 夹具验证美术包与普通入口请求和旧任务恢复;定向测试描述边界。 +4. 完成普通 Rust 目标检查、定向 Rust 测试、runtime_prompt_bundle_build、prompt_source_boundaries、skill-pack:check、check:doc-index、check:encoding 和 diff 检查;记录实际证据与限制。 + +## 检查点与风险 + +首轮以 45 分钟完成请求修改与定向验证为检查点;仅测试失败或验收缺口扩展范围。旧请求按持久快照恢复,不能修改原幂等身份或借升级新建付费操作。付费 Provider 验证不在本地夹具中执行。 + +## 回滚 + +回退本次客户端请求组装和随包文案即可;没有服务端、工具参数或持久化 schema 迁移,已冻结的新旧请求继续按原快照恢复。 + +## 验证完成 + +按计划完成美术包入口隔离、描述数组组装、共享文案、skill 摘要与主规范同步。53 项定向 Rust 测试、普通构建检查和文档 / 编码 / 差异门禁通过,逐项证据见里程碑。没有执行真实付费生成;本次不修改服务端、brief 长度合同或切片处理。 diff --git a/docs/project-memory/plans/【里程碑】AGC美术包生成需求入参修正-2026-10-05.md b/docs/project-memory/plans/【里程碑】AGC美术包生成需求入参修正-2026-10-05.md new file mode 100644 index 000000000..843181bb5 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC美术包生成需求入参修正-2026-10-05.md @@ -0,0 +1,33 @@ +# AGC 美术包生成需求入参修正 + +- Version: 1 +- Status: Implemented, Awaiting Acceptance +- Date: 2026-10-05 +- Parent Spec: [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-08-23-direct-codex-美术包显式重生成与切片投影) + +## 目标与范围 + +让美术包核心图集实际提交的 `iconDescriptions` 包含 brief 与现有内容、风格和排布要求。用户已确认此前方案并指示开工;实现前复核入口隔离、冻结请求恢复和服务端纯色底处理边界,评审通过。 + +必须完成请求组装、Agent 可见说明、定向测试和文档同步。风险项是普通图标入口串用模板、超长描述与重复付费恢复。范围外保留 brief 长度契约调整、固定四片和用途识别、重生成授权措辞及服务端行为修改。 + +## 验收标准 + +1. 新美术包图集的真实 HTTP 请求保留 brief,并在有效字段中包含内容、风格与间距要求;参考与比例/尺寸仍由正式字段承载。 +2. 普通图标入口保持原文单项提交,规范图与背景生成参数不变。 +3. 新增描述满足 API 条数、单条、总字符与字节限制;不截断或拆分用户原文。 +4. 旧 prepared 请求保持原 POST 字节及幂等键;accepted / 已完成请求不新建付费请求,brief 变化仍拒绝冒充同一意图。 +5. 工具参数无新增;工具说明、随包 skill 与主规范一致。提示词、skill 包、编码及差异检查通过;真实 Provider 效果独立标记。 + +## 证据 + +| 验收项 | 证据 | +| --- | --- | +| 新请求与普通入口隔离 | `art_package_spritesheet_posts_requirements_but_ordinary_icons_keep_original_text` 经生产生成入口和本地 HTTP 夹具检查真实请求及重复调用,已通过 | +| 原文与 API 边界 | `art_package_descriptions_keep_the_entire_brief_within_api_limits`、`art_package_description_limits_include_separators_and_utf8_bytes`,覆盖 200/201 码点、100/101 条、2000 字符及 6144 字节边界,已通过 | +| 旧任务恢复 | `art_package_spritesheet_recovers_old_requests_without_injecting_new_requirements` 覆盖 prepared / accepted / legacy-completed 三种旧单条请求;正文与幂等键不变,重复调用不新增生成 POST,已通过 | +| 相关回归 | 美术包过滤组 9 项、普通图标/背景/恢复/skill 包过滤组 20 项,全部通过 | +| 提示词与普通构建 | `runtime_prompt_bundle_build` 18 项、`prompt_source_boundaries` 6 项及 `cargo check --locked --offline --bin genarrative-ai-game-creator-shell` 通过 | +| 文档与资源 | skill-pack:check、check:doc-index、变更文件编码、rustfmt 和差异检查通过 | + +总计 53 项 Rust 测试通过。本地 HTTP 夹具需要监听回环端口,沙箱限制后已在获准的沙箱外环境重跑通过。真实客户端 / 付费 Provider 整包生成未执行;这些证据证明请求传输与恢复,不保证生成图片的视觉质量。固定四片、按序分配用途及 brief 长度不匹配仍属后续工作。本里程碑等待验收,不推进第三项。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2dc7c60c1..4d1c930ef 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -7,6 +7,7 @@ - **现象 / 根因**:调用方把构图等要求只写入 `generationInputs.artSpec`,但实际生图输入没有这些要求;把 `JsonValue` 和“可复用规范”误读为服务端会自动组装提示词、完整保留全部元数据或自动用于下一次生成。 - **现行边界**:整个 `generationInputs` 承载生成上下文、来源和应用元数据,保存规则取决于接口。场景与音效重建配方;图集原图可保存自定义字段,透明图集与切片另建处理阶段 / 来源元数据。已知字段仍有实际消费者,例如规范图的“游戏类型”、V2 面板恢复字段和客户端来源标记,不能把整个对象描述为无业务作用。 - **排查 / 使用**:同时核对正式请求字段、当前接口的重建 / 清理逻辑,以及最终资源 / 素材记录;重要要求必须进入 `prompt`、`iconDescriptions` 或场景结构化参数。项目与素材库读取可取得服务端实际保存的上下文,但它不等于完整 HTTP 重放载荷;重试仍保存原始请求和幂等键。 +- **AGC 美术包**:核心图集新请求将原 brief 与共用的风格、内容和间距要求分别写入 `iconDescriptions`;普通图标入口仍原文单项透传。新增模板只影响新请求,旧请求继续按冻结正文和操作身份恢复,不改原 brief 的意图判据。真实 HTTP 载荷与旧请求恢复必须同时验证,不能只测试 `artSpec` 包含文案。 - **权威说明**:[External v1 OpenAPI](../../openapi/genarrative-external-v1.openapi.json) 与 [API 指南的 Generation Inputs Metadata](../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md#generation-inputs-metadata)。本条记录现有行为,不引入 API、存储或 UI 行为变更。 ## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d7b725884..338845922 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1574,6 +1574,9 @@ game-project/ ## 2026-08-23 Direct Codex 美术包显式重生成与切片投影 +- 美术包核心图集的新请求必须把 brief 原文与客户端的素材内容、风格和排布要求共同写入 `iconDescriptions`。内容覆盖当前玩法需要的玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效;各素材独立排布并留出切分间距,沿用规范图的轮廓、材质、色板与光照。描述条数和内容类别不代表切片数量或返回顺序。规范图通过 `referenceId`、比例和尺寸通过正式参数传入;透明结果由服务端纯色底生成与抠图提供,不向模型追加直接生成透明背景的要求。`generationInputs.artSpec` 仅保留描述性上下文,不能作为要求已进入生成提示词的证据。 +- 美术包工具继续只接收 `brief` 与 `mode`,Agent 在 brief 中提供具体主题、风格、实体和反馈需求;客户端补齐通用要求。普通画布及 `agc_generate_image` 图标入口仍将 trim 后原文作为唯一描述项。美术包新增要求使用独立描述项并校验当前 API 的每条 200 字符、合计 2000 字符 / 6144 UTF-8 字节及 100 条上限,不挤占或截断原文;本次不扩展已有 brief 长度合同。只在没有冻结请求的新提交路径组装要求,已有 `prepared / accepted / legacy-completed` 请求按原请求体、幂等键与操作身份恢复,原 brief 的意图比较不受新增模板影响。验证必须覆盖实际 HTTP 请求、普通入口原文、描述边界及旧请求恢复,不以元数据或提示词文本存在代替传输证据。 +- 请求要求的自动化证据由 `art_package_spritesheet_posts_requirements_but_ordinary_icons_keep_original_text`、`art_package_spritesheet_recovers_old_requests_without_injecting_new_requirements` 与描述边界用例提供:生产生成入口经过本地 HTTP 夹具,核对真实 POST、原请求字节、幂等键及重复恢复零新增生成 POST。真实 Provider 的视觉效果不由请求夹具替代。 - 标准美术包在客户端将规范图、背景图和主图集统一保存为 PNG:下载仍校验来源、声明类型与文件签名,随后按真实内容接受 PNG/JPEG/WebP,在已有 20 MiB、4096 像素单边和 64 MiB 解码内存限制内完整解码。有效 PNG 原样保留,JPEG/WebP 编码成 PNG,最终内容也不得超过 20 MiB;本地媒体类型固定为 `image/png`。转码不补造透明度,主图集与独立切片继续执行真实 alpha、可见像素、尺寸和唯一性合同;平台独立切片仍须为 PNG。此行为仅属于美术包,普通图片工具的指定扩展名合同不变。 - 美术包的转码在项目提交锁和本地写入之前完成,文件摘要、已安装结果识别、替换恢复和 manifest 登记均使用最终 PNG。转码失败保留原生成账本与平台身份,同冻结意图重试重新读取已有结果,不提交新的付费生成;不改变请求快照、幂等身份、固定资源路径或旧 PNG 包的复用方式,无数据迁移。验收覆盖 JPEG/WebP 转码、PNG 字节不变、损坏/超限拒绝、真实透明度以及已有结果重复恢复零生成 POST;客户端真实 Provider 的整包验证单独记录。 - 转码自动化验收由 `canvas_generation_tests` 的格式/边界用例和 `retained_runtime_generation_retries_a_completed_stage_without_posting_again` 本地 HTTP 夹具覆盖:PNG/JPEG/WebP 均先下载损坏内容,再从同一已完成账本恢复两次,核对最终 PNG、稳定本地 asset ID、Canvas 来源身份、账本保留/清理及零生成 POST;替换与补偿继续由现有图集事务和 Direct 重生成用例覆盖。真实 Provider 的客户端整包效果不由这些夹具替代。