From 0c7fc0b26f907d22a18c47ea51abdd4104f348ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Thu, 4 Jun 2026 22:32:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8F=90=E5=8D=87=E6=8B=BC=E6=B6=88?= =?UTF-8?q?=E6=B6=88=E7=B4=A0=E6=9D=90=E7=94=9F=E6=88=90=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/pitfalls.md | 11 + ...法创作】拼消消玩法模板技术方案-2026-05-30.md | 13 +- server-rs/crates/api-server/src/app.rs | 3 +- .../api-server/src/creation_entry_config.rs | 8 +- server-rs/crates/api-server/src/http_error.rs | 4 + .../crates/api-server/src/puzzle_clear.rs | 734 +++++++++++++++++- 6 files changed, 724 insertions(+), 49 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index bcc0b543..a8bf72df 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1832,6 +1832,17 @@ - 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。 - 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`。 - 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。 + +## 拼消消素材错位先查 sheet 质量门禁 + +- 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。 +- 原因:provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。 +- 处理:先强化 atlas prompt,要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。 +- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格只表示玩法分组,不要暗示 provider 生成横跨多格的大图或照片拼贴。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁需要扫描单格内部贯穿大部分高度或宽度的强色差直线,命中时按“单格内部疑似拼接线”硬失败并重试 sheet。 +- 追加处理:sheet 生成时如果 VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed,应消耗同一 sheet 的下一次 attempt;仍失败再回写失败状态。 +- 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/puzzle_clear.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。 + ## 拼消消锁定组覆盖层必须锚定在棋盘本身 - 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。 diff --git a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md index ab968550..c81ab11e 100644 --- a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md +++ b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md @@ -46,9 +46,16 @@ 验证策略: -- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线和跨格主体。 -- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2`、`1x3`、`2x2`、`2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1,小图案内不显示切线;横向 `1x2` 按纵向切线分成两个 1x1,小图案内不显示切线;其他形状同理。 -- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;切片后还应对尺寸、非空像素比例和重复 hash 做校验。 +- 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上。 +- 同编号连续格只表示玩法上的同组关系,不再暗示 provider 把连续区域画成一张横跨多格的大图;同组格子用色调、道具和背景线索呼应,但每个 256x256 单元独立查看时都必须完整成图。 +- 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现。 +- 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。 +- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。 +- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2`、`1x3`、`2x2`、`2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1,横向 `1x2` 按纵向切线分成两个 1x1,其他形状同理。图案组可以在语义上成组,但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。 +- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形。 +- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部不得出现贯穿大部分高度或宽度的强色差拼接线;非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。 +- 每张 sheet 生成最多尝试 4 次;除质量门禁失败外,VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时也应消耗下一次 sheet attempt,避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。 +- sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。 - 首版若当前 provider 无法稳定产出可切 atlas,生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。 - 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey` 或 `placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。 - 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成;当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 85501df2..3b5bfbdb 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4080,8 +4080,7 @@ mod tests { .await .expect("banners body should collect") .to_bytes(); - let payload: Value = - serde_json::from_slice(&body).expect("banners payload should be json"); + let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json"); assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告"); assert_eq!(payload["eventBanners"][0]["renderMode"], "html"); diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 40801ed2..eabc55e9 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { { return Some("puzzle"); } - if normalized.starts_with("/api/runtime/puzzle/gallery/") - && normalized.ends_with("/remix") - { + if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") { return Some("puzzle"); } if normalized == "/api/runtime/big-fish/agent/sessions" { return Some("big-fish"); } - if normalized.starts_with("/api/runtime/big-fish/gallery/") - && normalized.ends_with("/remix") - { + if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") { return Some("big-fish"); } if normalized == "/api/runtime/custom-world/agent/sessions" diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 85699b70..ac061d6d 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -42,6 +42,10 @@ impl AppError { &self.message } + pub fn details(&self) -> Option<&Value> { + self.details.as_ref() + } + pub fn body_text(&self) -> String { // 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。 self.details diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 1ed5605f..878063f6 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -5,6 +5,7 @@ use axum::{ http::{HeaderName, StatusCode, header}, response::Response, }; +use image::GenericImageView; use module_assets::{ AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, @@ -61,6 +62,15 @@ const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10; const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536"; const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp"; +const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4; +const PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58; +const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018; +const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045; +const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34; +const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 155; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.86; +const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; pub async fn create_puzzle_clear_session( State(state): State, @@ -689,35 +699,104 @@ async fn maybe_prepare_puzzle_clear_assets_inner( payload.theme_prompt.as_deref().unwrap_or_default(), &sheet_spec, ); - let failure_context = format!("拼消消素材 {} 生成失败", sheet_spec.sheet_id); - let generated = create_openai_image_generation( - &http_client, - &settings, - sheet_prompt.as_str(), - Some("文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、低清晰度、主体跨格、主体贴边、重复同款小图"), - PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, - 1, - &[], - failure_context.as_str(), - ) - .await?; - let task_id = generated.task_id; - let image = generated.images.into_iter().next().ok_or_else(|| { - puzzle_clear_error_response( + let mut accepted_sheet = None; + for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS { + let failure_context = format!( + "拼消消素材 {} 生成失败,第 {} 次", + sheet_spec.sheet_id, + attempt_index + 1 + ); + let generated = match create_openai_image_generation( + &http_client, + &settings, + sheet_prompt.as_str(), + Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT), + PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, + 1, + &[], + failure_context.as_str(), + ) + .await + { + Ok(generated) => generated, + Err(error) + if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS + && is_retryable_puzzle_clear_sheet_generation_error(&error) => + { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + sheet_id = sheet_spec.sheet_id, + attempt = attempt_index + 1, + generation_error = %error.body_text(), + "拼消消素材 sheet 生成遇到可重试上游错误,准备重试" + ); + continue; + } + Err(error) => { + return Err(puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + error, + )); + } + }; + let task_id = generated.task_id; + let image = generated.images.into_iter().next().ok_or_else(|| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id), + })), + ) + })?; + match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) { + Ok(()) => { + accepted_sheet = Some(PuzzleClearGeneratedSheet { + spec: sheet_spec, + prompt: sheet_prompt.clone(), + task_id, + image, + }); + break; + } + Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + sheet_id = sheet_spec.sheet_id, + attempt = attempt_index + 1, + quality_error = %error.body_text(), + "拼消消素材 sheet 质量校验未通过,准备重试" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + sheet_id = sheet_spec.sheet_id, + attempt = attempt_index + 1, + quality_error = %error.body_text(), + "拼消消素材 sheet 质量校验最终未通过" + ); + return Err(puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + error, + )); + } + } + } + let Some(accepted_sheet) = accepted_sheet else { + return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id), + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id), })), - ) - })?; - generated_sheets.push(PuzzleClearGeneratedSheet { - spec: sheet_spec, - prompt: sheet_prompt, - task_id, - image, - }); + )); + }; + generated_sheets.push(accepted_sheet); } let mut slices = Vec::new(); @@ -858,7 +937,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:D02 D02 C01 C01\n", "第5行:D02 D02 C02 C02\n", "第6行:A01 A01 C02 C02\n\n", - "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域生成一幅完整连续的小插画。" + "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" ), }, PuzzleClearAtlasSheetSpec { @@ -879,7 +958,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:C03 C03 C04 C04\n", "第5行:B01 B01 B01 A06\n", "第6行:B03 B03 B03 A06\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域生成一幅完整连续的小插画。" + "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" ), }, PuzzleClearAtlasSheetSpec { @@ -900,7 +979,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:B05 B05 B05 A08\n", "第5行:A09 A09 A10 A08\n", "第6行:A11 A11 A10 空白\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。请按相同编号连续区域生成一幅完整连续的小插画。" + "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" ), }, PuzzleClearAtlasSheetSpec { @@ -921,7 +1000,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:A16 A19 A19 A18\n", "第5行:A20 A21 A21 A22\n", "第6行:A20 A23 A23 A22\n\n", - "A 表示 1x2 复合图案。请按相同编号连续区域生成一幅完整连续的小插画,横向 1x2 和纵向 1x2 都要自然可拼接。" + "A 表示 1x2 复合图案。相同编号只表示玩法分组:横向 1x2 和纵向 1x2 用色调、道具和背景线索互相呼应,每个 256 单元都要独立成图,不要把连续区域画成一张横跨两格的大图或照片拼贴。" ), }, ] @@ -936,9 +1015,14 @@ fn build_puzzle_clear_atlas_prompt( concat!( "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n", "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n", - "相同编号连续占据的格子是一幅复合小插画,必须形成同一个完整主题物件或小场景;不同编号之间是不同图案,不要重复主体。复合图案可以横向或纵向跨格,但跨格处必须自然连续,切成 1x1 后每一格仍然有清晰可识别的局部图案。\n\n", - "图案不要做成卡牌、贴纸、图标格子或带框小卡片。每个图案外沿自然融入干净浅色背景,但不能有过多留白,外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n", - "画风为高清、清爽、适合休闲消除游戏的小插画;主体饱满,颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n", + "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域必须有明确背景、环境、道具、光影和构图线索,像从一张丰富照片或插画中裁出的局部。\n\n", + "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n", + "相同编号连续占据的格子只表示玩法上的同组关系,不是要求把一张大图横跨多个格子。请把同编号区域画成色调、道具、背景线索互相呼应的一组小照片裁片;每个格子独立查看时都必须完整成图,不能在单格内部再切出第二张图或第二个场景。\n\n", + "同一张 sheet 内,不同编号必须使用不同视觉概念,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n", + "每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n", + "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;空白格必须保持干净浅色背景,不要出现任何图案碎片。\n\n", + "图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n", + "画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n", "{layout_prompt}" ), subject = subject, @@ -1063,6 +1147,376 @@ async fn persist_puzzle_clear_data_url_asset( .await } +#[derive(Clone, Copy, Debug)] +struct PuzzleClearSheetCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +impl PuzzleClearSheetCellBounds { + fn width(self) -> u32 { + self.x1.saturating_sub(self.x0).max(1) + } + + fn height(self) -> u32 { + self.y1.saturating_sub(self.y0).max(1) + } + + fn area(self) -> u32 { + self.width().saturating_mul(self.height()).max(1) + } +} + +#[derive(Clone, Copy, Debug)] +struct PuzzleClearSheetCellQuality { + foreground_ratio: f32, + exposed_edge_count: usize, + strongest_edge_ratio: f32, + strongest_internal_seam_ratio: f32, +} + +fn validate_puzzle_clear_sheet_quality( + image: &DownloadedOpenAiImage, + sheet_spec: &PuzzleClearAtlasSheetSpec, +) -> Result<(), AppError> { + // 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。 + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id), + })) + })?; + let source_width = source.width(); + let source_height = source.height(); + if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id), + })), + ); + } + + let mut findings = Vec::new(); + let mut advisory_findings = Vec::new(); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let group_id = sheet_spec.layout[row as usize][col as usize]; + let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); + let quality = + analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds); + let cell_label = format!("第{}行第{}列", row + 1, col + 1); + if group_id == "." { + if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { + findings.push(format!("{cell_label} 空白格有主体")); + } + continue; + } + + if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { + findings.push(format!("{cell_label} 主体过少")); + } + if quality.strongest_internal_seam_ratio + > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD + { + findings.push(format!("{cell_label} 单格内部疑似拼接线")); + } + if quality.exposed_edge_count >= 2 + && quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD + { + advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界")); + } + } + } + + if !advisory_findings.is_empty() { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + sheet_id = sheet_spec.sheet_id, + quality_warning = %advisory_findings.join(";"), + "拼消消素材 sheet 检测到边界接触,已作为提示继续切片" + ); + } + + if findings.is_empty() { + return Ok(()); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "reason": "invalid_puzzle_clear_sheet_quality", + "message": format!( + "拼消消素材 {} 不满足切片质量:{}。请重新生成图集。", + sheet_spec.sheet_id, + findings.join(";"), + ), + "findings": findings, + })), + ) +} + +fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool { + if !matches!( + error.status_code(), + StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS + ) { + return false; + } + error + .details() + .and_then(|details| details.get("retryable")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn analyze_puzzle_clear_sheet_cell_quality( + source: &image::DynamicImage, + sheet_spec: &PuzzleClearAtlasSheetSpec, + row: u32, + col: u32, + bounds: PuzzleClearSheetCellBounds, +) -> PuzzleClearSheetCellQuality { + let background = sample_puzzle_clear_sheet_cell_background(source, bounds); + let width = bounds.width() as usize; + let height = bounds.height() as usize; + let mut mask = vec![0u8; width.saturating_mul(height)]; + let mut foreground_pixels = 0u32; + for local_y in 0..height { + let y = bounds.y0 + local_y as u32; + for local_x in 0..width { + let x = bounds.x0 + local_x as u32; + if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { + mask[local_y * width + local_x] = 1; + foreground_pixels = foreground_pixels.saturating_add(1); + } + } + } + + let (exposed_edge_count, strongest_edge_ratio) = + measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col); + let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds); + PuzzleClearSheetCellQuality { + foreground_ratio: foreground_pixels as f32 / bounds.area() as f32, + exposed_edge_count, + strongest_edge_ratio, + strongest_internal_seam_ratio, + } +} + +fn puzzle_clear_sheet_cell_bounds( + row: u32, + col: u32, + source_width: u32, + source_height: u32, +) -> PuzzleClearSheetCellBounds { + let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS); + let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS); + let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS) + .max(x0 + 1) + .min(source_width); + let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS) + .max(y0 + 1) + .min(source_height); + PuzzleClearSheetCellBounds { x0, y0, x1, y1 } +} + +fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { + if pixel[3] <= 24 { + return false; + } + let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs(); + let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + + (pixel[1] as i32 - background[1] as i32).abs() + + (pixel[2] as i32 - background[2] as i32).abs(); + alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD +} + +fn sample_puzzle_clear_sheet_cell_background( + source: &image::DynamicImage, + bounds: PuzzleClearSheetCellBounds, +) -> [u8; 4] { + let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8); + let points = [ + (bounds.x0, bounds.y0), + (bounds.x1.saturating_sub(sample_size), bounds.y0), + (bounds.x0, bounds.y1.saturating_sub(sample_size)), + ( + bounds.x1.saturating_sub(sample_size), + bounds.y1.saturating_sub(sample_size), + ), + ]; + let mut samples = Vec::new(); + for (start_x, start_y) in points { + let mut totals = [0u32; 4]; + let mut count = 0u32; + for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) { + for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) { + let pixel = source.get_pixel(x, y).0; + totals[0] = totals[0].saturating_add(pixel[0] as u32); + totals[1] = totals[1].saturating_add(pixel[1] as u32); + totals[2] = totals[2].saturating_add(pixel[2] as u32); + totals[3] = totals[3].saturating_add(pixel[3] as u32); + count = count.saturating_add(1); + } + } + if count > 0 { + samples.push([ + (totals[0] / count) as u8, + (totals[1] / count) as u8, + (totals[2] / count) as u8, + (totals[3] / count) as u8, + ]); + } + } + + samples + .into_iter() + .max_by_key(|sample| { + let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16; + let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16; + let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; + let saturation = max_channel.saturating_sub(min_channel); + (luminance, u16::MAX.saturating_sub(saturation)) + }) + .unwrap_or([255, 255, 255, 255]) +} + +fn measure_puzzle_clear_sheet_exposed_edges( + mask: &[u8], + width: usize, + height: usize, + sheet_spec: &PuzzleClearAtlasSheetSpec, + row: u32, + col: u32, +) -> (usize, f32) { + if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) { + return (0, 0.0); + } + let band = (width.min(height) / 24).clamp(6, 12); + let mut exposed_edges = 0usize; + let mut strongest_ratio = 0.0f32; + let edge_specs = [ + ((-1i32, 0i32), 0usize, 0usize, width, band), + ((1, 0), 0, height.saturating_sub(band), width, band), + ((0, -1), 0, 0, band, height), + ((0, 1), width.saturating_sub(band), 0, band, height), + ]; + + for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs { + if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) { + continue; + } + let mut foreground = 0usize; + let mut total = 0usize; + for local_y in start_y..start_y.saturating_add(edge_height).min(height) { + for local_x in start_x..start_x.saturating_add(edge_width).min(width) { + total = total.saturating_add(1); + if mask[local_y * width + local_x] != 0 { + foreground = foreground.saturating_add(1); + } + } + } + if total == 0 { + continue; + } + let ratio = foreground as f32 / total as f32; + strongest_ratio = strongest_ratio.max(ratio); + if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD { + exposed_edges = exposed_edges.saturating_add(1); + } + } + + (exposed_edges, strongest_ratio) +} + +fn measure_puzzle_clear_sheet_internal_seam( + source: &image::DynamicImage, + bounds: PuzzleClearSheetCellBounds, +) -> f32 { + let width = bounds.width(); + let height = bounds.height(); + if width < 48 || height < 48 { + return 0.0; + } + let margin_x = (width / 8).clamp(18, 36); + let margin_y = (height / 8).clamp(18, 36); + let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1); + let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1); + let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1); + let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1); + let mut strongest = 0.0f32; + + for x in x_start..x_end { + let mut strong = 0u32; + let mut total = 0u32; + for y in y_start..y_end { + let left = source.get_pixel(x.saturating_sub(1), y).0; + let right = source.get_pixel(x, y).0; + if puzzle_clear_rgb_distance(left, right) + >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD + { + strong = strong.saturating_add(1); + } + total = total.saturating_add(1); + } + if total > 0 { + strongest = strongest.max(strong as f32 / total as f32); + } + } + + for y in y_start..y_end { + let mut strong = 0u32; + let mut total = 0u32; + for x in x_start..x_end { + let top = source.get_pixel(x, y.saturating_sub(1)).0; + let bottom = source.get_pixel(x, y).0; + if puzzle_clear_rgb_distance(top, bottom) + >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD + { + strong = strong.saturating_add(1); + } + total = total.saturating_add(1); + } + if total > 0 { + strongest = strongest.max(strong as f32 / total as f32); + } + } + + strongest +} + +fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 { + (left[0] as i32 - right[0] as i32).abs() + + (left[1] as i32 - right[1] as i32).abs() + + (left[2] as i32 - right[2] as i32).abs() +} + +fn puzzle_clear_sheet_neighbor_is_same_group( + sheet_spec: &PuzzleClearAtlasSheetSpec, + row: u32, + col: u32, + row_delta: i32, + col_delta: i32, +) -> bool { + let current = sheet_spec.layout[row as usize][col as usize]; + if current == "." { + return false; + } + let neighbor_row = row as i32 + row_delta; + let neighbor_col = col as i32 + col_delta; + if neighbor_row < 0 + || neighbor_col < 0 + || neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32 + || neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32 + { + return false; + } + sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current +} + fn slice_puzzle_clear_sheet( image: &DownloadedOpenAiImage, sheet_spec: &PuzzleClearAtlasSheetSpec, @@ -1617,12 +2071,19 @@ fn build_puzzle_clear_public_work_code(profile_id: &str) -> String { #[cfg(test)] mod tests { use super::{ - PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, - build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt, - build_puzzle_clear_draft, planned_puzzle_clear_pattern_groups, - puzzle_clear_atlas_sheet_specs, + PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, + PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, build_puzzle_clear_atlas_prompt, + build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft, + is_retryable_puzzle_clear_sheet_generation_error, planned_puzzle_clear_pattern_groups, + puzzle_clear_atlas_sheet_specs, validate_puzzle_clear_sheet_quality, }; + use crate::http_error::AppError; + use crate::openai_image_generation::DownloadedOpenAiImage; + use axum::http::StatusCode; + use image::{ImageFormat, Rgba, RgbaImage}; + use serde_json::json; use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest; + use std::io::Cursor; #[test] fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() { @@ -1635,12 +2096,38 @@ mod tests { assert!(prompt.contains("竖版 1024x1536")); assert!(prompt.contains("4 列 x 6 行裁切")); assert!(prompt.contains("256x256 的正方形")); - assert!(prompt.contains("切成 1x1 后每一格")); - assert!(prompt.contains("图案不要做成卡牌、贴纸、图标格子或带框小卡片")); + assert!(prompt.contains("完整的单场景照片裁片")); + assert!(prompt.contains("照片式构图")); + assert!(prompt.contains("主题微场景拼图卡")); + assert!(prompt.contains("明确背景、环境、道具、光影和构图线索")); + assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片")); + assert!(prompt.contains("禁止在一个单元内部出现两张照片")); + assert!(prompt.contains("内部竖切")); + assert!(prompt.contains("内部横切")); + assert!(prompt.contains("场景变化只能发生在 256 单元边界上")); + assert!(prompt.contains("相同编号连续占据的格子只表示玩法上的同组关系")); + assert!(prompt.contains("不是要求把一张大图横跨多个格子")); + assert!(prompt.contains("不能在单格内部再切出第二张图或第二个场景")); + assert!(prompt.contains("不同编号必须使用不同视觉概念")); + assert!(prompt.contains("不要把同一种主体换角度、换大小、换姿势后重复使用")); + assert!(prompt.contains("果园、集市摊位、野餐布、果汁杯、厨房案板")); + assert!(prompt.contains("可以包含主体局部、背景纹理、桌面、草地、天空")); + assert!(prompt.contains("不要让小卡只有一个孤立主体加纯色背景")); + assert!(prompt.contains("空白格必须保持干净浅色背景")); + assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片")); assert!(prompt.contains("外轮廓框")); assert!(prompt.contains("贴纸边")); assert!(prompt.contains("圆角框")); assert!(prompt.contains("阴影框")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴")); + assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴")); assert!(!prompt.contains("135 幅")); assert!(!prompt.contains("24 列 x 38 行")); assert!(!prompt.contains("卡牌小格")); @@ -1684,6 +2171,177 @@ mod tests { } } + #[test] + fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() { + let sheet = puzzle_clear_atlas_sheet_specs() + .into_iter() + .find(|sheet| sheet.sheet_id == "sheet-04") + .expect("sheet exists"); + let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); + for row in 0..6u32 { + for col in 0..4u32 { + let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let color = Rgba([ + 70u8.saturating_add((row * 23) as u8), + 80u8.saturating_add((col * 31) as u8), + 160, + 255, + ]); + for y in base_y + 80..base_y + 176 { + for x in base_x + 80..base_x + 176 { + source.put_pixel(x, y, color); + } + } + } + } + + for y in 0..180u32 { + for x in 0..180u32 { + source.put_pixel(x, y, Rgba([215, 48, 62, 255])); + } + } + + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, ImageFormat::Png) + .expect("test image should encode"); + let image = DownloadedOpenAiImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: encoded.into_inner(), + }; + + validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect("edge contact is advisory because generated sheets often touch borders"); + } + + #[test] + fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() { + let sheet = puzzle_clear_atlas_sheet_specs() + .into_iter() + .find(|sheet| sheet.sheet_id == "sheet-03") + .expect("sheet exists"); + let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); + for row in 0..6u32 { + for col in 0..4u32 { + let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let color = Rgba([ + 70u8.saturating_add((row * 23) as u8), + 80u8.saturating_add((col * 31) as u8), + 160, + 255, + ]); + for y in base_y + 80..base_y + 176 { + for x in base_x + 80..base_x + 176 { + source.put_pixel(x, y, color); + } + } + } + } + + for y in 5 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..6 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { + for x in 3 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..4 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { + source.put_pixel(x, y, Rgba([215, 48, 62, 255])); + } + } + + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, ImageFormat::Png) + .expect("test image should encode"); + let image = DownloadedOpenAiImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: encoded.into_inner(), + }; + + let error = validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect_err("blank cell pollution should be rejected"); + assert!(error.body_text().contains("空白格有主体")); + } + + #[test] + fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() { + let sheet = puzzle_clear_atlas_sheet_specs() + .into_iter() + .find(|sheet| sheet.sheet_id == "sheet-04") + .expect("sheet exists"); + let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); + for row in 0..6u32 { + for col in 0..4u32 { + let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let color = Rgba([ + 70u8.saturating_add((row * 23) as u8), + 80u8.saturating_add((col * 31) as u8), + 160, + 255, + ]); + for y in base_y + 80..base_y + 176 { + for x in base_x + 80..base_x + 176 { + source.put_pixel(x, y, color); + } + } + } + } + + for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { + for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 { + source.put_pixel(x, y, Rgba([206, 46, 62, 255])); + } + for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE { + source.put_pixel(x, y, Rgba([38, 112, 218, 255])); + } + } + + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, ImageFormat::Png) + .expect("test image should encode"); + let image = DownloadedOpenAiImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: encoded.into_inner(), + }; + + let error = validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect_err("internal photo seam should be rejected"); + assert!(error.body_text().contains("单格内部疑似拼接线")); + } + + #[test] + fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() { + let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "上游服务请求失败", + "retryable": true, + })); + assert!(is_retryable_puzzle_clear_sheet_generation_error( + &retryable_error + )); + + let non_retryable_gateway = + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "上游服务请求失败", + "retryable": false, + })); + assert!(!is_retryable_puzzle_clear_sheet_generation_error( + &non_retryable_gateway + )); + + let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "vector-engine", + "message": "请求参数不合法", + "retryable": true, + })); + assert!(!is_retryable_puzzle_clear_sheet_generation_error( + &bad_request + )); + } + #[test] fn puzzle_clear_board_background_prompt_reveals_theme_goal() { let prompt = build_puzzle_clear_board_background_prompt("星港花园");