diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/art_package_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/art_package_tests.rs new file mode 100644 index 000000000..13f6832db --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/art_package_tests.rs @@ -0,0 +1,507 @@ +use super::tests::{ + register_direct_taonier_art_base_fixture, tiny_strict_slice_png, + write_direct_taonier_spritesheet_stage_result_fixture, +}; +use super::*; +use serde_json::{json, Value}; +use std::io::Read; +use std::net::{TcpListener, TcpStream}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +const BRIEF: &str = "农村经营素材:农民、小麦、谷仓和收获特效"; +const API_KEY: &str = "package-completion-fixture"; +const LEDGER: &str = + ".agent/runtime/canvas-generation-requests/direct-codex-art/art-spritesheet.json"; +const GENERATION_ROUTE: &str = "/api/external/v1/editor/icon-spritesheets/generations"; + +#[derive(Clone, Copy, Default)] +enum RemoteState { + #[default] + Missing, + Recoverable, + QueryFailed, + GenerationFailed, +} + +/// 仅提供回环响应;记录完整请求以验证新生成和恢复的付费边界。 +struct PackageServer { + base_url: String, + result: Value, + requests: Arc>>, + stop: mpsc::Sender<()>, + thread: Option>, +} + +impl PackageServer { + fn start(remote: RemoteState, slices: usize) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let result = json!({ + "spritesheetResource": { + "resourceId": "package-sheet", "assetObjectId": "package-sheet-object", + "projectId": "taonier-project", "taskId": "package-task", + "objectKey": "package.png", + }, + "sliceMode": "connected-components", + "iconImageSrcs": (0..slices).map(|i| json!({ + "name": format!("素材 {}", i + 1), "width": 2, "height": 1, + "resource": { + "resourceId": format!("slice-{i}"), "assetObjectId": format!("slice-object-{i}"), + "projectId": "taonier-project", "taskId": "package-task", + "sourceResourceId": "package-sheet", "objectKey": format!("slice-{i}.png"), + }, + })).collect::>(), + }); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = requests.clone(); + let response = result.clone(); + let server_url = base_url.clone(); + let (stop, stopped) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(30); + while stopped.try_recv().is_err() && Instant::now() < deadline { + let (mut stream, _) = match listener.accept() { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept fixture request: {error}"), + }; + let request = read_request(&mut stream); + recorded.lock().unwrap().push(request.clone()); + let first_line = request.lines().next().unwrap(); + if first_line == "GET /api/external/v1/editor/projects/taonier-project HTTP/1.1" { + if matches!(remote, RemoteState::QueryFailed) { + respond( + &mut stream, + "503 Service Unavailable", + &json!({"error":"fixture query failed"}), + ); + } else { + let resources = if matches!(remote, RemoteState::Recoverable) { + vec![json!({ + "resourceId":"package-sheet", "assetObjectId":"package-sheet-object", + "projectId":"taonier-project", "taskId":"package-task", + "assetKind":"icon-spritesheet", "sourceResourceId":"uploaded-spec", + "objectKey":"package.png", + })] + } else { + Vec::new() + }; + respond( + &mut stream, + "200 OK", + &json!({"project":{"resources":resources}}), + ); + } + } else if first_line == format!("POST {GENERATION_ROUTE} HTTP/1.1") { + respond( + &mut stream, + "202 Accepted", + &json!({"data":{ + "operationId":"package-operation", "status":"queued", "pollAfterMs":0, + }}), + ); + } else if first_line + == "GET /api/external/v1/generations/package-operation HTTP/1.1" + { + let data = if matches!(remote, RemoteState::GenerationFailed) { + json!({"operationId":"package-operation", "status":"failed", + "error":"夹具图集生成失败"}) + } else { + json!({"operationId":"package-operation", "status":"completed", "result":response}) + }; + respond(&mut stream, "200 OK", &json!({"data":data})); + } else if first_line.starts_with("GET /api/external/v1/assets/read-url?") { + let object = (0..slices) + .map(|i| format!("slice-{i}.png")) + .find(|name| first_line.contains(name)) + .unwrap_or_else(|| "package.png".into()); + respond( + &mut stream, + "200 OK", + &json!({"read":{"signedUrl":format!("{server_url}/{object}")}}), + ); + } else if first_line == "GET /package.png HTTP/1.1" + || first_line.starts_with("GET /slice-") + { + let index = first_line + .strip_prefix("GET /slice-") + .and_then(|value| value.split_once('.')) + .map(|(index, _)| index.parse::().unwrap()) + .unwrap_or(slices); + let bytes = tiny_strict_slice_png(index); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len()).unwrap(); + stream.write_all(&bytes).unwrap(); + } else if first_line == "GET /api/external/v1/editor/projects?view=summary HTTP/1.1" + { + respond( + &mut stream, + "200 OK", + &json!({"data":{"projects":[{"projectId":"taonier-project"}]}}), + ); + } else if first_line + == "GET /api/external/v1/editor/assets/folders/taonier-assets HTTP/1.1" + { + respond( + &mut stream, + "200 OK", + &json!({"data":{"folder":{"folderId":"taonier-assets"}}}), + ); + } else { + respond( + &mut stream, + "400 Bad Request", + &json!({"error":"unexpected fixture request"}), + ); + } + } + }); + Self { + base_url, + result, + requests, + stop, + thread: Some(thread), + } + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + async fn prepare(&self, root: &Path, brief: &str) -> Result { + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + self.base_url.clone(), + API_KEY.into(), + ), + ensure_direct_taonier_art_package_at( + root, + brief, + DirectTaonierArtPreparationMode::ReuseOrCreate, + ), + ) + .await + } +} + +impl Drop for PackageServer { + fn drop(&mut self) { + let _ = self.stop.send(()); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +fn read_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + loop { + let mut buffer = [0; 4096]; + let count = stream.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") { + let length = String::from_utf8_lossy(&bytes[..end]) + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + break; + } + } + } + String::from_utf8(bytes).unwrap() +} + +fn respond(stream: &mut TcpStream, status: &str, value: &Value) { + let body = value.to_string(); + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); +} + +fn project(server: &PackageServer) -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + init_local_game_project_at(root, "package-completion", "美术包补齐").unwrap(); + register_direct_taonier_art_base_fixture(root); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: Default::default(), + }, + ) + .unwrap(); + let access = ExternalEditorBindingAccess::for_developer(&server.base_url, API_KEY).unwrap(); + let principal = external_editor_binding_principal(&access).unwrap(); + let manifest = read_manifest_for_project(root).unwrap(); + let binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + "taonier-project", + "taonier-assets", + unix_timestamp(), + ) + .unwrap(); + write_external_editor_project_binding_at(root, &binding).unwrap(); + let spec = &base_assets(root)[0].1; + let source = new_external_editor_source_identity( + &spec.id, + &format!( + "{:x}", + Sha256::digest(std::fs::read(root.join(&spec.local_path)).unwrap()) + ), + &spec.media_type, + &spec.kind, + ) + .unwrap(); + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + "taonier-project", + &source, + Some("uploaded-spec"), + "spec.png", + "uploaded-spec-object", + Some(1), + Some(1), + unix_timestamp(), + ) + .unwrap(); + write_external_editor_resource_binding_at(root, &binding).unwrap(); + temp +} + +fn base_assets(root: &Path) -> Vec<(Vec, GameCreationAppAssetManifestEntry)> { + let manifest = read_manifest_for_project(root).unwrap(); + [ + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + ] + .iter() + .map(|path| { + ( + std::fs::read(root.join(path)).unwrap(), + manifest + .assets + .iter() + .find(|asset| asset.local_path == *path) + .unwrap() + .clone(), + ) + }) + .collect() +} + +fn seed_request(root: &Path, server: &PackageServer, status: &str, reference: &str) -> Vec { + write_direct_taonier_spritesheet_stage_result_fixture( + root, + reference, + "package-sheet", + "package-sheet-object", + "package-task", + ); + let mut value: Value = + serde_json::from_slice(&std::fs::read(root.join(LEDGER)).unwrap()).unwrap(); + let body = json!({"referenceId":reference, "projectId":"taonier-project", "assetFolderId":"taonier-assets", + "iconDescriptions":[BRIEF], "sliceMode":"connected-components", "aspectRatio":"1:1", "imageSize":"1K", + "generationInputs":{"artSpec":{"assetType":"art"}}}).to_string(); + value["requestBodySha256"] = format!("{:x}", Sha256::digest(body.as_bytes())).into(); + value["requestBodyJson"] = body.into(); + value["generationPrompt"] = BRIEF.into(); + value["externalConfigurationFingerprint"] = + platform_art_generation_external_service_fingerprint(&server.base_url, None).into(); + value["externalServiceOrigin"] = server.base_url.clone().into(); + value["status"] = if status == "retained" { + "accepted" + } else { + status + } + .into(); + value["operationId"] = if status == "prepared" { + Value::Null + } else { + "package-operation".into() + }; + value["pollAfterMs"] = if status == "prepared" { + Value::Null + } else { + 0.into() + }; + value["legacyResult"] = if status == "retained" { + server.result.clone() + } else { + Value::Null + }; + let bytes = serde_json::to_vec_pretty(&value).unwrap(); + std::fs::write(root.join(LEDGER), &bytes).unwrap(); + bytes +} + +#[tokio::test] +async fn base_only_package_generates_only_the_missing_sheet_and_reuses_completed_output() { + let server = PackageServer::start(RemoteState::Missing, 2); + let temp = project(&server); + let root = temp.path(); + let before = base_assets(root); + let package = server.prepare(root, BRIEF).await.unwrap(); + assert_eq!(package.asset_paths, DIRECT_CODEX_ART_ASSET_PATHS); + assert_eq!(package.slice_paths.len(), 2); + assert_eq!(base_assets(root), before); + assert!(!root.join(LEDGER).exists()); + let requests = server.requests(); + let posts = requests + .iter() + .filter(|request| request.starts_with("POST ")) + .collect::>(); + assert_eq!(posts.len(), 1, "{requests:#?}"); + assert!(posts[0].starts_with(&format!("POST {GENERATION_ROUTE} "))); + let body: Value = serde_json::from_str(posts[0].split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["referenceId"], "uploaded-spec"); + assert_eq!(body["iconDescriptions"][0], BRIEF); + assert_eq!(server.prepare(root, BRIEF).await.unwrap(), package); + assert_eq!(server.requests(), requests, "完整包复用无需网络请求"); +} + +#[tokio::test] +async fn retained_sheet_request_takes_priority_over_remote_discovery() { + for status in ["prepared", "accepted", "retained"] { + let server = PackageServer::start(RemoteState::QueryFailed, 2); + let temp = project(&server); + let root = temp.path(); + let before = base_assets(root); + let frozen = seed_request(root, &server, status, "uploaded-spec"); + let package = server.prepare(root, BRIEF).await.unwrap(); + assert_eq!(package.slice_paths.len(), 2); + assert_eq!(base_assets(root), before); + let requests = server.requests(); + assert!( + !requests + .iter() + .any(|r| r.starts_with("GET /api/external/v1/editor/projects/taonier-project ")), + "不得以资源查找绕过账本" + ); + let posts = requests + .iter() + .filter(|r| r.starts_with("POST ")) + .collect::>(); + assert_eq!( + posts.len(), + usize::from(status == "prepared"), + "{requests:#?}" + ); + if let Some(post) = posts.first() { + let frozen: Value = serde_json::from_slice(&frozen).unwrap(); + assert_eq!( + post.split_once("\r\n\r\n").unwrap().1, + frozen["requestBodyJson"].as_str().unwrap() + ); + assert!(post + .to_ascii_lowercase() + .contains("idempotency-key: test-spritesheet-idempotency-key")); + } + assert_eq!( + requests + .iter() + .any(|r| r.starts_with("GET /api/external/v1/generations/package-operation ")), + status != "retained" + ); + } +} + +#[tokio::test] +async fn query_and_generation_failures_preserve_base_images_and_return_errors() { + for state in [RemoteState::QueryFailed, RemoteState::GenerationFailed] { + let server = PackageServer::start(state, 0); + let temp = project(&server); + let before = base_assets(temp.path()); + let error = server.prepare(temp.path(), BRIEF).await.unwrap_err(); + assert!(error.contains("规范图和背景图已保留"), "{error}"); + assert!(error.contains("核心图集未完成"), "{error}"); + assert_eq!(base_assets(temp.path()), before); + assert!(!temp + .path() + .join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .exists()); + let requests = server.requests(); + assert_eq!( + requests.iter().filter(|r| r.starts_with("POST ")).count(), + usize::from(matches!(state, RemoteState::GenerationFailed)), + "{requests:#?}" + ); + assert!( + error.contains(if matches!(state, RemoteState::QueryFailed) { + "503" + } else { + "夹具图集生成失败" + }), + "{error}" + ); + } +} + +#[tokio::test] +async fn conflicting_or_corrupt_request_never_falls_back_to_fresh_generation() { + for invalid in ["brief", "reference", "corrupt"] { + let server = PackageServer::start(RemoteState::Recoverable, 0); + let temp = project(&server); + let root = temp.path(); + let before = base_assets(root); + seed_request( + root, + &server, + "accepted", + if invalid == "reference" { + "foreign-spec" + } else { + "uploaded-spec" + }, + ); + if invalid == "corrupt" { + std::fs::write(root.join(LEDGER), b"invalid ledger").unwrap(); + } + let frozen = std::fs::read(root.join(LEDGER)).unwrap(); + let error = server + .prepare( + root, + if invalid == "brief" { + "另一套美术需求" + } else { + BRIEF + }, + ) + .await + .unwrap_err(); + assert!( + error.contains(DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX), + "{error}" + ); + assert_eq!(std::fs::read(root.join(LEDGER)).unwrap(), frozen); + assert_eq!(base_assets(root), before); + assert!(!server.requests().iter().any(|r| r.starts_with("POST ") + || r.starts_with("GET /api/external/v1/editor/projects/taonier-project "))); + } +} + +#[test] +fn package_result_rejects_base_images_without_a_spritesheet() { + let root = tempfile::tempdir().unwrap(); + init_local_game_project_at(root.path(), "incomplete-package", "不完整美术包").unwrap(); + register_direct_taonier_art_base_fixture(root.path()); + let error = direct_taonier_art_package_result(root.path(), Vec::new(), Vec::new()).unwrap_err(); + assert!(error.contains("美术包未完成"), "{error}"); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index e79458ac9..7da689a94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3,6 +3,8 @@ use base64::Engine as _; use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(test)] +mod art_package_tests; mod user_input; #[cfg(not(test))] pub(crate) use user_input::enqueue_direct_codex_turn; @@ -3250,35 +3252,31 @@ async fn generate_direct_taonier_art_asset_at( fn direct_taonier_art_package_result( root: &Path, - asset_paths: Vec, warnings: Vec, mut slice_warnings: Vec, -) -> DirectTaonierArtPackage { - let includes_spritesheet = asset_paths - .iter() - .any(|path| path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH); - if includes_spritesheet { - if let Err(error) = register_existing_platform_art_slices_at(root) { - slice_warnings.push(format!("现有图集切片补登记失败:{error}")); - } +) -> Result { + if !direct_taonier_art_package_is_valid(root) { + return Err("陶泥儿美术包未完成:规范图、背景图或核心图集缺失或不可验证".to_string()); } - let slice_paths = if includes_spritesheet && direct_taonier_art_package_is_valid(root) { - direct_registered_taonier_slice_paths(root) - } else { - Vec::new() - }; - if includes_spritesheet && slice_paths.is_empty() { + if let Err(error) = register_existing_platform_art_slices_at(root) { + slice_warnings.push(format!("现有图集切片补登记失败:{error}")); + } + let slice_paths = direct_registered_taonier_slice_paths(root); + if slice_paths.is_empty() { slice_warnings.push( "当前图集没有可验证的独立切片;请查看总图后决定使用或进一步处理,不得猜测坐标或伪造素材身份" .to_string(), ); } - DirectTaonierArtPackage { - asset_paths, + Ok(DirectTaonierArtPackage { + asset_paths: DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect(), slice_paths, warnings, slice_warnings, - } + }) } pub(crate) async fn ensure_direct_taonier_art_package_at( @@ -3305,15 +3303,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( emit_direct_game_creator_progress(root, "art.prepare", "正在检查陶泥儿美术包"); if !mode.regenerates_existing() && direct_taonier_art_package_is_valid(root) { emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); - return Ok(direct_taonier_art_package_result( - root, - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - Vec::new(), - Vec::new(), - )); + return direct_taonier_art_package_result(root, Vec::new(), Vec::new()); } let mut regeneration_workflow = if mode.regenerates_existing() { let invocation_id = active_turn_id_at(root)?; @@ -3364,9 +3354,6 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( ) }) .flatten(); - let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { - direct_taonier_background_asset_identity(root, Some(art_spec)).is_some() - }); let art_spec = match existing_art_spec { Some(identity) => identity, None => { @@ -3499,51 +3486,23 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( } if !mode.regenerates_existing() && direct_taonier_art_package_is_valid(root) { emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); - return Ok(direct_taonier_art_package_result( - root, - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - warnings, - slice_warnings, - )); + return direct_taonier_art_package_result(root, warnings, slice_warnings); } + // 补齐缺失图集时优先恢复原请求,不能用远端资源查找绕过阶段账本。 + let spritesheet_request_exists = game_creator_agent_runtime_external_generation_exists( + root, + DIRECT_CODEX_ART_AGENT_ID, + "art-spritesheet", + ); if !mode.regenerates_existing() - && recover_direct_taonier_spritesheet_read_only_at(root, &art_spec).await? + && !spritesheet_request_exists + && recover_direct_taonier_spritesheet_read_only_at(root, &art_spec) + .await + .map_err(|error| format!("{error};规范图和背景图已保留,核心图集未完成"))? { - if direct_taonier_art_package_is_valid(root) { - emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); - return Ok(direct_taonier_art_package_result( - root, - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - warnings, - slice_warnings, - )); - } - } - if existing_art_spec_and_background { - if !direct_taonier_art_base_is_valid(root) { - return Err("当前项目已有的陶泥儿规范图或背景图不再满足可下载、可解码和来源一致性要求,已终止代码生成".to_string()); - } - emit_direct_game_creator_progress( - root, - "art.ready", - "核心图集未能安全恢复,已使用现有陶泥儿规范图和背景图继续创作", - ); - slice_warnings.push("未能安全恢复核心图集,已仅返回规范图和背景图".to_string()); - return Ok(direct_taonier_art_package_result( - root, - vec![ - DIRECT_CODEX_ART_SPEC_ASSET_PATH.to_string(), - DIRECT_CODEX_BACKGROUND_ASSET_PATH.to_string(), - ], - warnings, - slice_warnings, - )); + let package = direct_taonier_art_package_result(root, warnings, slice_warnings)?; + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(package); } if let Some(workflow) = regeneration_workflow.as_mut() { if let Err(error) = begin_direct_taonier_strict_spritesheet_commit_at(root, workflow) { @@ -3571,6 +3530,10 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( { Ok(outcome) => outcome, Err(error) => { + // 补齐缺失图集失败时不回滚或重做已经有效的基础素材,保留原错误前缀。 + if !mode.regenerates_existing() { + return Err(format!("{error};规范图和背景图已保留,核心图集未完成")); + } return Err(direct_taonier_strict_spritesheet_stage_failure( root, regeneration_rollback.as_ref(), @@ -3590,15 +3553,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 严格图集提交后整包身份未通过回读;已保留新结果、workflow 与三阶段账本,禁止局部补偿或自动重发" )); } - let package = direct_taonier_art_package_result( - root, - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - warnings, - slice_warnings, - ); + let package = direct_taonier_art_package_result(root, warnings, slice_warnings)?; let package = if let Some(workflow) = regeneration_workflow.as_ref() { complete_direct_taonier_regeneration_workflow_at(root, workflow, package)? } else { @@ -6432,16 +6387,13 @@ mod tests { let completed_result = direct_taonier_art_package_result( root.path(), - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), vec![format!( "平台告警包含 {} 与 https://secret.example/path", root.path().display() )], vec!["切片告警保持原样".to_string()], - ); + ) + .expect("complete package result"); let completed_result = complete_direct_taonier_regeneration_workflow_at( root.path(), &workflow, @@ -6513,15 +6465,8 @@ mod tests { .expect("record completed predecessor background identity"); persist_direct_taonier_regeneration_rollback_at(root.path(), &mut workflow, &rollback) .expect("persist completed predecessor identities"); - let package = direct_taonier_art_package_result( - root.path(), - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - Vec::new(), - Vec::new(), - ); + let package = direct_taonier_art_package_result(root.path(), Vec::new(), Vec::new()) + .expect("complete package result"); write_direct_taonier_spritesheet_stage_result_fixture( root.path(), "taonier-resource-icon-spec", @@ -8128,15 +8073,8 @@ mod tests { } register_existing_platform_art_slices_at(root).unwrap(); assert!(direct_taonier_strict_art_package_is_valid(root)); - let result = direct_taonier_art_package_result( - root, - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|p| (*p).to_string()) - .collect(), - vec![], - vec![], - ); + let result = direct_taonier_art_package_result(root, vec![], vec![]) + .expect("complete package result"); assert_eq!( result.slice_paths, (0..count) @@ -8189,7 +8127,7 @@ mod tests { } } - fn write_direct_taonier_spritesheet_stage_result_fixture( + pub(super) fn write_direct_taonier_spritesheet_stage_result_fixture( root: &Path, art_spec_resource_id: &str, spritesheet_resource_id: &str, @@ -8403,7 +8341,7 @@ mod tests { .expect("register replacement fixture"); } - fn register_direct_taonier_art_base_fixture(root: &Path) { + pub(super) fn register_direct_taonier_art_base_fixture(root: &Path) { std::fs::create_dir_all(root.join("assets")).expect("assets dir"); register_direct_taonier_art_asset_fixture( root, @@ -8473,7 +8411,7 @@ mod tests { bytes } - fn tiny_strict_slice_png(index: usize) -> Vec { + pub(super) fn tiny_strict_slice_png(index: usize) -> Vec { let red = 40_u8.saturating_add((index as u8).saturating_mul(40)); let mut pixels = image::RgbaImage::new(2, 1); pixels.put_pixel(0, 0, image::Rgba([red, 96, 224, 255])); @@ -8777,15 +8715,8 @@ mod tests { register_direct_taonier_art_slice_entries_fixture(root.path()); std::fs::write(root.path().join("game/index.html"), "").expect("index"); std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style"); - let package = direct_taonier_art_package_result( - root.path(), - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - Vec::new(), - Vec::new(), - ); + let package = direct_taonier_art_package_result(root.path(), Vec::new(), Vec::new()) + .expect("complete package result"); assert_eq!(package.slice_paths.len(), 4); std::fs::write( @@ -8812,15 +8743,9 @@ mod tests { ) .expect("opaque slice"); assert!(direct_game_output_completion_error(root.path()).is_some()); - let invalid_package = direct_taonier_art_package_result( - root.path(), - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - Vec::new(), - Vec::new(), - ); + let invalid_package = + direct_taonier_art_package_result(root.path(), Vec::new(), Vec::new()) + .expect("valid main images with unusable slices still complete"); assert!(invalid_package.slice_paths.is_empty()); assert!(invalid_package .slice_warnings @@ -8922,15 +8847,8 @@ mod tests { .expect("remove slice registrations"); assert!(direct_taonier_art_package_is_valid(root.path())); - let package = direct_taonier_art_package_result( - root.path(), - DIRECT_CODEX_ART_ASSET_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect(), - Vec::new(), - Vec::new(), - ); + let package = direct_taonier_art_package_result(root.path(), Vec::new(), Vec::new()) + .expect("valid main images without slices still complete"); assert!(package.slice_paths.is_empty()); assert!(package.warnings.is_empty()); assert_eq!(package.slice_warnings.len(), 1); diff --git a/docs/project-memory/plans/【实施计划】AGC图集实际产物与语义识别-2026-10-05.md b/docs/project-memory/plans/【实施计划】AGC图集实际产物与语义识别-2026-10-05.md index a421a0687..4c57ab3c7 100644 --- a/docs/project-memory/plans/【实施计划】AGC图集实际产物与语义识别-2026-10-05.md +++ b/docs/project-memory/plans/【实施计划】AGC图集实际产物与语义识别-2026-10-05.md @@ -1,6 +1,6 @@ # AGC 图集实际产物与语义识别实施计划 -- Version: 1 +- Version: 2 - Status: Implemented, Awaiting Acceptance - Date: 2026-10-05 - Parent Spec: [里程碑](./【里程碑】AGC图集实际产物与语义识别-2026-10-05.md) @@ -26,3 +26,18 @@ - 回环 HTTP 测试在允许监听端口的环境运行;真实付费 Provider 与视觉试玩未运行。 具体条款与证据见对应里程碑。 + +## 2026-10-06 验收缺口修正 + +- 用户已确认本轮只修复第二次调用的续生成与完成判据。范围为 Direct 美术包编排、结果构造、对应 Rust 测试和文档;第三次显式重生成的身份比较问题单独处理。 +- 先删除已有基础图时返回两图的分支;已有图集阶段账本优先进入原恢复逻辑,没有账本才查询可恢复资源,查询错误直接返回。 +- 美术包结果构造返回 Result,固定返回三张通过现有校验的主图;保持零切片告警和原有错误传递,补齐失败明确基础图保留状态。 +- 新增五项回归,仅覆盖只生成缺失图集、prepared/accepted/已保留结果恢复、身份冲突或账本损坏、查询或生成失败,以及两图包不能返回完成;验证实际 POST 数量、冻结请求与基础图文件/登记不变。零片/多片和图片校验边界复用已有覆盖。 +- 定向运行 Direct runtime、canvas generation、tool bridge / MCP 测试及 Rust 检查;运行格式、文档索引、编码和 diff 检查。每段约 30 分钟检查一次范围,不调用真实付费 Provider、不修改用户项目现场。回滚仅涉及本次代码与文档,无持久化格式迁移。 + +### 本次验证证据 + +- `cargo test --locked --offline --bin genarrative-ai-game-creator-shell direct_runtime -- --test-threads=1`:106 项通过,含五项新增回归。 +- 同一测试可执行文件的 `canvas_generation_tests`、`direct_tool_bridge`、`mcp` 分组:118 / 44 / 53 项通过。 +- `cargo check --locked --offline --bin genarrative-ai-game-creator-shell` 通过;现有编译告警未在本轮扩大处理。 +- 修改文件的 rustfmt、编码、文档索引与 diff 检查通过。HTTP 夹具只访问本机回环端口;未调用真实 Provider,也未修改用户项目现场。 diff --git a/docs/project-memory/plans/【里程碑】AGC图集实际产物与语义识别-2026-10-05.md b/docs/project-memory/plans/【里程碑】AGC图集实际产物与语义识别-2026-10-05.md index 4da00feae..9b0d234a1 100644 --- a/docs/project-memory/plans/【里程碑】AGC图集实际产物与语义识别-2026-10-05.md +++ b/docs/project-memory/plans/【里程碑】AGC图集实际产物与语义识别-2026-10-05.md @@ -1,6 +1,6 @@ # AGC 图集实际产物与语义识别 -- Version: 1 +- Version: 2 - Status: Implemented, Awaiting Acceptance - Date: 2026-10-05 - Parent Spec: [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-08-23-direct-codex-美术包显式重生成与切片投影) @@ -11,6 +11,8 @@ 必须移除客户端数量参数及按顺序赋义,按实际产物返回完整身份和可查看图片。风险项为动态集合事务、旧活动账本恢复与零切片告警;可选的自动语义分类、补切工作流不实施。服务端 API 与 OpenAPI 不变。 +2026-10-06 用户复核发现复用两张基础图时跳过缺失图集、并将不完整包返回 completed;用户已评审补齐流程与完成判据方案并指示修复提交。本次为同一里程碑验收缺口修正,不处理显式重生成的参考资源 ID 比较错误,不改输入长度校验。 + ## 验收 1. 美术包及普通新请求完全不含 sliceCount;工具不接受该参数,保留模式和网格参数。 @@ -19,6 +21,8 @@ 4. Agent 说明明确内容要求不是严格数量或用途保证,先看图再处理;本地产物通过既有工具登记。 5. 完成账本保留切分声明,历史零切片账本缺少声明时带告警保留总图;事务与重生成恢复核对动态集合;旧固定事务、旧用途文件和旧已付费请求继续可恢复且不增加生成 POST。 6. 定向测试、Rust 检查、提示词与 skill 包、文档索引、编码及 diff 检查通过;真实 Provider 未验证部分明确记录。 +7. 已有规范图和背景图但缺图集时,仅补齐图集;已有请求优先恢复原身份,无请求才只读查找并按需生成。查询失败、身份冲突或账本损坏不能触发新的生成。 +8. 整包只有三张主图有效才返回 completed;图集失败保留已有基础图并返回明确错误,零切片和实际多切片仍按原合同交付。恢复用例证明无新增付费请求,基础图的文件和登记保持不变。 ## 验收证据 @@ -30,6 +34,8 @@ | 4 Agent 说明 | 工具与随包 skill 改为看图识别、按实际内容处理;skill-pack:check 与提示词测试通过 | | 5 恢复与兼容 | 新旧请求三种状态及旧输出槽 HTTP 夹具保留原正文/幂等身份,已受理任务无新生成 POST;动态事务中断恢复、路径白名单、原固定合同与 Direct 重生成回归通过;动态包复用和快照漂移检测覆盖 0/2/4/6 片 | | 6 工程检查 | 生成组 118、Direct 运行时 93、工具桥 44、MCP 32、账本 6、提示词 24,共 317 项 Rust 测试通过;cargo check、rustfmt、skill 包、文档索引、变更文件编码及 diff 检查通过 | +| 7 补齐与恢复(2026-10-06) | 五项新增回归覆盖只生成缺失图集、三种请求状态恢复、查询/生成失败、身份冲突/账本损坏和完成判据;断言只提交所需图集请求,已受理或已有结果不新增生成 POST,基础图文件与登记不变 | +| 8 完成与保留(2026-10-06) | 两图包结果构造返回错误;图集失败返回原原因及基础图保留状态;已有零片/多片、图片校验与重生成测试继续通过。此次 Direct 106、生成 118、工具桥 44、MCP 相关 53 项通过,Rust check、格式、编码、文档索引和 diff 检查通过 | 使用本地 HTTP 夹具验证生产入口,没有调用真实付费 Provider,也没有客户端视觉试玩;这些测试不证明素材语义正确。用户安装 PyYAML 6.0.3 后,使用 `python3` 重跑通用 skill `quick_validate.py`,随包 `taonier-art-assets` 校验通过(`Skill is valid!`);仓库 skill 包完整性检查和提示词/随包 skill 回归也已通过。原无参数全仓编码命令受沙箱子进程限制,按真实变更文件列表重跑通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0cbe4d5e6..7d28bc92c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6548,6 +6548,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 图集请求中的 sliceCount 不影响模型提示词,只会约束服务端后处理;客户端不再暴露或发送它,服务端 API 仍保留现状。四类内容需求不等于四个连通域,也不能按返回次序或历史 player 等文件名分配用途。 - 新切片按源资源摘要隔离、以中性序号保存;普通清单记录实际总图路径,美术包回执按实际集合验证。工具返回全部身份与路径标注预览,Agent 看图识别后使用或处理;零片仍交付有效总图与告警。 - 动态集合必须同时进入事务 journal、重生成快照、完整性检查和工具投影。旧数量请求需先定位原动作槽并使用原请求体、幂等键和 operation,候选不唯一则对账;不能删掉指纹字段后直接发起新付费请求。完成结果要保留 sliceMode/gridX/gridY,否则重放会丢失服务端回显。 +- 美术包仅有规范图和背景图时,`reuse-or-create` 必须继续补齐图集,不能把两图包返回为 completed。补齐优先恢复现有阶段请求;无请求才只读查找并按需生成,查询失败、身份冲突或账本损坏不能当作资源不存在而重发。补齐失败保留有效基础图,三张主图齐全且可验证后才交付;零切片告警不等于缺失总图。 ## 2026-10-05 在线游玩「一直黑屏」:加载面缺失 + 发行网关不压不发 ETag diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ad079e0c7..4b3a92e65 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1602,6 +1602,8 @@ game-project/ - 客户端图集入口不暴露或发送 `sliceCount`;保留 `sliceMode` 与网格参数。服务端 API、OpenAPI 及服务端数量限制保持现状。四类内容只是生成需求,不能推导四个连通域、切片数量或返回次序对应的语义。 - 美术包和普通图集按实际返回的 0–256 张切片保存、登记与返回。有效透明总图没有独立切片时仍完成并返回切片告警;Agent 必须看图确认实体、状态、用途及缺失内容,再决定使用、进一步本地处理或补充生成。已有授权、付费幂等与资产登记规则继续适用,不能虚构切片、坐标或 Canvas 身份。 +- 美术包 `reuse-or-create` 复用有效规范图和背景图,继续补齐缺失图集。补齐时若图集阶段有持久化请求,优先恢复原请求、原幂等键和操作身份,不以远端资源查找绕过账本;没有请求时才尝试只读恢复已有图集,确认没有可恢复产物后才发起图集生成。查询失败、账本损坏或身份冲突返回错误并保留现场,不把失败解释为不存在而新建请求,也不重生成已有效的前两张图。完整本地包仍按已有规则直接复用。 +- 美术包成功结果统一要求规范图、背景图和总图通过已有文件、来源与登记校验,并返回这三张主图及实际可验证切片;缺失任一主图时返回工具错误,不返回 `completed`。有效总图没有独立切片仍为完成并附切片告警。补齐图集失败时保留已有规范图、背景图和可恢复请求,明确告知图集未完成及真实原因;游戏工程本身允许复用已有基础素材的验收规则不随之收紧。 - 新切片使用源图集身份隔离的目录与中性序号,不再生成按 player 等用途命名的别名。普通图集的清单记录实际总图路径并保存在自己的目录;美术包公共清单与私有回执只证明来源、完整性和实际产物集合,不表达语义分类。历史四用途路径仅白名单兼容读取与恢复,文件名不能证明用途。 - 本地事务按固定合同文件与本次实际切片集合冻结快照、提交及恢复,保留摘要、真实 alpha、可见像素、唯一身份、项目归属、资源预算和 CAS 校验。重生成的旧快照包含实际旧切片;历史固定集合事务仍可恢复,不重写旧账本或请求体。普通调用升级前携带数量的活动请求必须在新 POST 前被定位并继续使用原身份;多个可能候选时失败关闭,不能任选或重复付费。 - 图集完成结果账本保留响应的 `sliceMode/gridX/gridY`,以支持同身份重放。历史账本已丢失切分声明且返回零切片时,声明无法用于证明切片方式;保留有效总图并明确告警,不因此重发生成。存在实际切片时仍要求切分声明一致。