Merge remote-tracking branch 'origin/master' into codex/yace

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
This commit is contained in:
kdletters
2026-05-16 23:05:38 +08:00
64 changed files with 10425 additions and 1095 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6016,13 +6016,16 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let mut transparent_pixel_count = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if pixels[offset + 3] == 0 {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
}
}
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte避免误伤贴边主体。
@@ -6136,6 +6139,98 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
}
}
if has_transparent_background {
let mut visible_mask = vec![0u8; pixel_count];
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if is_match3d_material_visible_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
visible_mask[pixel_index] = 1;
}
}
for _ in 0..2 {
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if visible_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_match3d_material_green_contaminated_edge_pixel(pixel) {
continue;
}
if !touches_match3d_material_background_mask(
x,
y,
width,
height,
&background_mask,
) {
continue;
}
if is_match3d_material_strong_green_contamination(pixel) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
visible_mask[pixel_index] = 0;
background_mask[pixel_index] = 1;
changed = true;
changed_this_round = true;
continue;
}
let replacement = collect_match3d_material_visible_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&visible_mask,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
changed = true;
changed_this_round = true;
}
background_mask[pixel_index] = 1;
}
}
if !changed_this_round {
break;
}
}
}
changed
}
@@ -6167,6 +6262,98 @@ fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool {
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
if pixel[3] == 0 {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
}
fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool {
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
}
fn collect_match3d_material_visible_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -3i32..=3 {
for offset_x in -3i32..=3 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let pixel = [
pixels[next_offset],
pixels[next_offset + 1],
pixels[next_offset + 2],
next_alpha,
];
if is_match3d_material_green_contaminated_edge_pixel(pixel)
|| is_match3d_material_soft_edge_pixel(pixel)
{
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
2.0
} else if distance <= 3 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -7571,6 +7758,37 @@ mod tests {
);
}
#[test]
fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() {
let width = 64;
let height = 64;
let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
for y in 16..48 {
for x in 16..48 {
if x <= 18 || x >= 45 || y <= 18 || y >= 45 {
view.put_pixel(x, y, image::Rgba([42, 118, 36, 255]));
} else {
view.put_pixel(x, y, image::Rgba([174, 92, 72, 255]));
}
}
}
let cleaned =
crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8();
assert!(
cleaned.pixels().all(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha == 0 || green <= red.max(blue).saturating_add(18)
}),
"暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边"
);
assert!(
cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]),
"暗绿轮廓清理不能误删物品主体"
);
}
#[test]
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
let width = 500;
@@ -8388,6 +8606,7 @@ mod tests {
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: None,
}),
messages: Vec::new(),
last_assistant_reply: None,
@@ -8472,6 +8691,131 @@ mod tests {
);
}
#[test]
fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() {
let assets = vec![Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()),
image_src: Some(
"/generated-match3d-assets/session/profile/items/strawberry/view-01.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "果园背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
container_prompt: Some("果园容器".to_string()),
container_image_src: Some(
"/generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
status: "image_ready".to_string(),
error: None,
}];
let session = Match3DAgentSessionRecord {
session_id: "match3d-session-1".to_string(),
current_turn: 3,
progress_percent: 100,
stage: "DraftCompiled".to_string(),
anchor_pack: Match3DAnchorPackRecord {
theme: Match3DAnchorItemRecord {
key: "theme".to_string(),
label: "题材主题".to_string(),
value: "水果".to_string(),
status: "confirmed".to_string(),
},
clear_count: Match3DAnchorItemRecord {
key: "clearCount".to_string(),
label: "消除次数".to_string(),
value: "12".to_string(),
status: "confirmed".to_string(),
},
difficulty: Match3DAnchorItemRecord {
key: "difficulty".to_string(),
label: "难度".to_string(),
value: "4".to_string(),
status: "confirmed".to_string(),
},
},
config: None,
draft: Some(Match3DResultDraftRecord {
profile_id: "match3d-profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string(), "抓大鹅".to_string()],
cover_image_src: None,
reference_image_src: None,
clear_count: 12,
difficulty: 4,
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
}),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-15T00:00:00.000Z".to_string(),
};
let response = map_match3d_agent_session_response_with_assets(session, &[]);
let draft = response.draft.expect("session draft should exist");
assert_eq!(draft.generated_item_assets.len(), 1);
assert_eq!(
draft.background_image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft.background_image_object_key.as_deref(),
Some("generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft
.generated_background_asset
.as_ref()
.and_then(|asset| asset.container_image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/ui-container/container.png")
);
assert_eq!(
draft.generated_item_assets[0]
.background_asset
.as_ref()
.and_then(|asset| asset.image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
@@ -8761,9 +9105,59 @@ mod tests {
assert_eq!(response.generated_item_assets.len(), 1);
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
assert_eq!(response.generated_item_assets[0].status, "image_ready");
assert_eq!(response.generation_status.as_deref(), Some("generating"));
assert_eq!(
response.generated_item_assets[0].image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
);
}
#[test]
fn match3d_work_summary_marks_complete_generated_assets_ready() {
let assets = vec![Match3DGeneratedItemAsset {
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "水果厨房背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background.png".to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background.png".to_string(),
),
container_prompt: None,
container_image_src: Some(
"/generated-match3d-assets/session/profile/container.png".to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/container.png".to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
..test_match3d_generated_item_asset(1, "草莓")
}];
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
});
assert_eq!(response.generation_status.as_deref(), Some("ready"));
}
}

View File

@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
if generated_item_assets.is_empty() {
return response;
}
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
pub(super) fn map_match3d_draft_response(
draft: Match3DResultDraftRecord,
) -> Match3DResultDraftResponse {
Match3DResultDraftResponse {
// 中文注释session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
let generated_item_assets = parse_match3d_generated_item_assets(
draft.generated_item_assets_json.as_deref(),
)
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
let mut response = Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
generated_item_assets: generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect(),
};
if response
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
}
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
response
}
pub(super) fn map_match3d_generated_item_asset_for_agent(
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
item
}
fn match3d_text_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| asset.image_views.iter().any(|view| {
match3d_text_present(view.image_src.as_ref())
|| match3d_text_present(view.image_object_key.as_ref())
})
}
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Option<String> {
if item.publication_status.eq_ignore_ascii_case("published") {
return Some("ready".to_string());
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)
{
return Some("generating".to_string());
}
Some("ready".to_string())
}
pub(super) fn map_match3d_message_response(
message: Match3DAgentMessageRecord,
) -> Match3DAgentMessageResponse {
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generation_status = resolve_match3d_work_generation_status(
&item,
&generated_item_asset_json,
background_asset.as_ref(),
);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
generation_status,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()

View File

@@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
error
}
fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
}
async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
@@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates(
"message": "AI 重绘需要提供参考图。",
}))
})?;
create_puzzle_vector_engine_image_edit(
let edit_result = create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
@@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates(
count,
reference_image,
)
.await
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
@@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates(
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
None,
)
.await
}
@@ -4263,6 +4296,7 @@ mod tests {
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
@@ -4278,6 +4312,40 @@ mod tests {
);
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
};
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
"参考图里的小猫做成拼图主图。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
1,
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(
images[0]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
@@ -4363,6 +4431,39 @@ mod tests {
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
@@ -4834,6 +4935,7 @@ mod tests {
);
assert_eq!(response.levels.len(), 1);
assert_eq!(response.generation_status.as_deref(), Some("ready"));
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
@@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
@@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt,
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let request_started_at = Instant::now();
@@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation(
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image = false,
has_reference_image = reference_image.is_some(),
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
@@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Value {
Value::Object(Map::from_iter([
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(image_model.request_model_name().to_string()),
@@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body(
),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]))
]);
if let Some(reference_image) = reference_image
&& let Some(reference_data_url) =
build_puzzle_generation_reference_image_data_url(reference_image)
{
body.insert("image".to_string(), json!([reference_data_url]));
}
Value::Object(body)
}
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
@@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag
)
}
fn build_puzzle_generation_reference_image_data_url(
image: &PuzzleResolvedReferenceImage,
) -> Option<String> {
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
.unwrap_or_else(|| image.bytes.clone());
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
"image/png"
} else {
image.mime_type.as_str()
};
Some(format!(
"data:{};base64,{}",
normalize_puzzle_downloaded_image_mime_type(mime_type),
BASE64_STANDARD.encode(bytes)
))
}
fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
let image = image::load_from_memory(bytes).ok()?;
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut cursor = std::io::Cursor::new(Vec::new());
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
Some(cursor.into_inner())
}
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
reference_image_src
.map(str::trim)
@@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error(
) -> AppError {
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
upstream_status = upstream_status.as_u16(),
timeout = is_timeout,
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 VectorEngine 上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
"timeout": is_timeout,
}))
}

View File

@@ -278,10 +278,31 @@ pub(super) fn map_puzzle_result_preview_finding_response(
}
}
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "generating")
.or_else(|| {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "ready")
})
.or_else(|| {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| !status.is_empty())
})
.map(str::to_string)
}
pub(super) fn map_puzzle_work_summary_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let generation_status = resolve_puzzle_work_generation_status(&item);
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
@@ -316,6 +337,7 @@ pub(super) fn map_puzzle_work_summary_response(
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
generation_status,
levels: Vec::new(),
}
}

View File

@@ -237,7 +237,9 @@ pub fn confirm_click_at(
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
}
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
let Some(slot_index) =
insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index)
else {
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
};
@@ -246,7 +248,6 @@ pub fn confirm_click_at(
next.items[item_index].state = Match3DItemState::InTray;
next.items[item_index].clickable = false;
next.items[item_index].tray_slot_index = Some(slot_index);
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
compact_tray(&mut next);
@@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
.map(|slot| slot.slot_index)
}
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item.item_instance_id.clone());
slot.item_type_id = Some(item.item_type_id.clone());
slot.visual_key = Some(item.visual_key.clone());
fn insert_item_into_tray_after_same_type(
slots: &mut [Match3DTraySlot],
items: &mut [Match3DItemSnapshot],
item_index: usize,
) -> Option<u32> {
let occupied = slots
.iter()
.filter_map(|slot| {
Some((
slot.item_instance_id.clone()?,
slot.item_type_id.clone()?,
slot.visual_key.clone()?,
))
})
.collect::<Vec<_>>();
if occupied.len() >= slots.len() {
return None;
}
let item = items.get(item_index)?.clone();
let insertion_index = occupied
.iter()
.rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id)
.map(|index| index + 1)
.unwrap_or(occupied.len());
let mut next_occupied = occupied;
next_occupied.insert(
insertion_index,
(
item.item_instance_id.clone(),
item.item_type_id.clone(),
item.visual_key.clone(),
),
);
for slot in slots.iter_mut() {
slot.item_instance_id = None;
slot.item_type_id = None;
slot.visual_key = None;
}
for (index, (item_instance_id, item_type_id, visual_key)) in
next_occupied.into_iter().enumerate()
{
let slot_index = index as u32;
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item_instance_id.clone());
slot.item_type_id = Some(item_type_id);
slot.visual_key = Some(visual_key);
}
if let Some(entry) = items
.iter_mut()
.find(|entry| entry.item_instance_id == item_instance_id)
{
entry.tray_slot_index = Some(slot_index);
}
}
Some(insertion_index as u32)
}
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
@@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<S
slot.visual_key = None;
}
}
compact_tray(run);
matched_slot_item_ids
}
@@ -1005,8 +1059,16 @@ mod tests {
for item in board_items {
let quadrant = format!(
"{}-{}",
if item.x >= MATCH3D_BOARD_CENTER { "r" } else { "l" },
if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" },
if item.x >= MATCH3D_BOARD_CENTER {
"r"
} else {
"l"
},
if item.y >= MATCH3D_BOARD_CENTER {
"b"
} else {
"t"
},
);
*quadrants.entry(quadrant).or_default() += 1;
}
@@ -1108,6 +1170,82 @@ mod tests {
);
}
#[test]
fn clicking_item_inserts_after_same_type_and_shifts_following_slots() {
let mut run = Match3DRunSnapshot {
run_id: "run-insert".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
status: Match3DRunStatus::Running,
started_at_ms: 0,
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
clear_count: 3,
total_item_count: 4,
cleared_item_count: 0,
board_version: 1,
items: vec![
manual_item("apple-3", "apple", None),
manual_item("apple-1", "apple", Some(0)),
manual_item("apple-2", "apple", Some(1)),
manual_item("pear-1", "pear", Some(2)),
],
tray_slots: empty_tray_slots(),
failure_reason: None,
last_confirmed_action_id: None,
};
run.tray_slots[0].item_instance_id = Some("apple-1".to_string());
run.tray_slots[0].item_type_id = Some("apple".to_string());
run.tray_slots[0].visual_key = Some("apple".to_string());
run.tray_slots[1].item_instance_id = Some("apple-2".to_string());
run.tray_slots[1].item_type_id = Some("apple".to_string());
run.tray_slots[1].visual_key = Some("apple".to_string());
run.tray_slots[2].item_instance_id = Some("pear-1".to_string());
run.tray_slots[2].item_type_id = Some("pear".to_string());
run.tray_slots[2].visual_key = Some("pear".to_string());
let confirmed = confirm_click_at(
&run,
&Match3DClickInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
item_instance_id: "apple-3".to_string(),
client_action_id: "action-insert".to_string(),
snapshot_version: run.board_version,
clicked_at_ms: 1_000,
},
)
.expect("click should confirm");
assert_eq!(confirmed.entered_slot_index, Some(2));
assert_eq!(
confirmed
.run
.tray_slots
.iter()
.map(|slot| slot.item_instance_id.as_deref())
.collect::<Vec<_>>(),
vec![Some("pear-1"), None, None, None, None, None, None]
);
assert_eq!(
confirmed
.run
.items
.iter()
.find(|item| item.item_instance_id == "pear-1")
.and_then(|item| item.tray_slot_index),
Some(0)
);
assert_eq!(
confirmed.cleared_item_instance_ids,
vec![
"apple-1".to_string(),
"apple-2".to_string(),
"apple-3".to_string()
]
);
}
#[test]
fn tray_full_fails_when_no_triple_can_clear() {
let mut run = Match3DRunSnapshot {

View File

@@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel>
.next()
}
fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.find(|level| {
level
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string)
.is_some()
|| level
.ui_background_image_object_key
.as_deref()
.and_then(normalize_required_string)
.is_some()
})
}
fn resolve_puzzle_runtime_ui_background_fields(
level: Option<&PuzzleDraftLevel>,
fallback_level: Option<&PuzzleDraftLevel>,
) -> (Option<String>, Option<String>) {
for candidate in [level, fallback_level].into_iter().flatten() {
let image_src = candidate
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string);
let object_key = candidate
.ui_background_image_object_key
.as_deref()
.and_then(|value| normalize_required_string(value.trim_start_matches('/')));
if image_src.is_some() || object_key.is_some() {
return (image_src, object_key);
}
}
(None, None)
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
@@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at(
let grid_size = level_config.grid_size;
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let current_profile_level = first_profile_level(entry_profile);
let ui_background_level = first_profile_ui_background_level(entry_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at(
author_display_name: entry_profile.author_display_name.clone(),
theme_tags: entry_profile.theme_tags.clone(),
cover_image_src: entry_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1326,6 +1367,16 @@ pub fn advance_next_level_at(
let mut played_profile_ids = run.played_profile_ids.clone();
played_profile_ids.push(next_profile.profile_id.clone());
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (mut ui_background_image_src, mut ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() {
ui_background_image_src = current_level.ui_background_image_src.clone();
ui_background_image_object_key = current_level.ui_background_image_object_key.clone();
}
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1347,12 +1398,8 @@ pub fn advance_next_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at(
played_profile_ids.push(next_profile.profile_id.clone());
}
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -3151,8 +3200,7 @@ mod tests {
.background_music
.as_ref()
.map(|music| music.audio_src.as_str()),
Some("/generated-puzzle-assets/background.mp3".to_string())
.as_deref()
Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref()
);
assert_eq!(
current_level.ui_background_image_object_key.as_deref(),
@@ -3175,8 +3223,8 @@ mod tests {
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run =
advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run");
let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000)
.expect("next run");
assert_eq!(
next_run
@@ -3187,6 +3235,52 @@ mod tests {
);
}
#[test]
fn same_work_next_level_inherits_first_available_ui_background() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/entry-ui.png".to_string());
profile.levels.push(PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
});
let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_level = selected_profile_level_after_runtime_level(&profile, current_level)
.expect("same work next level");
let mut next_profile = profile.clone();
next_profile.level_name = next_level.level_name.clone();
next_profile.cover_image_src = next_level.cover_image_src.clone();
next_profile.cover_asset_id = next_level.cover_asset_id.clone();
next_profile.levels = vec![next_level];
let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(
next_run
.current_level
.as_ref()
.and_then(|level| level.ui_background_image_src.as_deref()),
Some("/generated-puzzle-assets/entry-ui.png")
);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);

View File

@@ -151,6 +151,8 @@ pub struct Match3DWorkSummaryResponse {
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
#[serde(default)]
pub generation_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -282,4 +284,36 @@ mod tests {
assert_eq!(payload["gameName"], json!("水果抓大鹅"));
assert_eq!(payload["clearCount"], json!(4));
}
#[test]
fn match3d_work_summary_uses_camel_case_generation_status() {
let payload = serde_json::to_value(Match3DWorkSummaryResponse {
work_id: "work-1".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("session-1".to_string()),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
reference_image_src: None,
clear_count: 4,
difficulty: 5,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-01T00:00:00Z".to_string(),
published_at: None,
publish_ready: false,
generation_status: Some("generating".to_string()),
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
})
.expect("payload should serialize");
assert_eq!(payload["generationStatus"], json!("generating"));
}
}

View File

@@ -57,6 +57,8 @@ pub struct PuzzleWorkSummaryResponse {
pub point_incentive_claimable_points: u64,
pub publish_ready: bool,
#[serde(default)]
pub generation_status: Option<String>,
#[serde(default)]
pub levels: Vec<PuzzleDraftLevelResponse>,
}
@@ -91,6 +93,7 @@ mod tests {
point_incentive_total_points: 1.5,
point_incentive_claimable_points: 0,
publish_ready: true,
generation_status: Some("ready".to_string()),
levels: Vec::new(),
})
.expect("payload should serialize");
@@ -99,6 +102,7 @@ mod tests {
assert_eq!(payload["pointIncentiveClaimedPoints"], 1);
assert_eq!(payload["pointIncentiveTotalPoints"], 1.5);
assert_eq!(payload["pointIncentiveClaimablePoints"], 0);
assert_eq!(payload["generationStatus"], "ready");
}
}

View File

@@ -3195,6 +3195,7 @@ fn map_match3d_result_draft(
total_item_count: snapshot.clear_count.saturating_mul(3),
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: snapshot.generated_item_assets_json,
}
}
@@ -6398,6 +6399,7 @@ pub struct Match3DResultDraftRecord {
pub total_item_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6541,6 +6543,8 @@ struct Match3DDraftJsonRecord {
tags: Vec<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]

View File

@@ -459,6 +459,11 @@ fn compile_match3d_draft_tx(
config.theme_text.as_str(),
);
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let draft = Match3DDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: game_name.clone(),
@@ -467,12 +472,9 @@ fn compile_match3d_draft_tx(
tags: tags.clone(),
clear_count: config.clear_count,
difficulty: config.difficulty,
// 中文注释:草稿响应本身也携带生成素材快照,避免 HTTP facade 回读 work 详情失败时丢失背景/容器图。
generated_item_assets_json: generated_item_assets_json.clone(),
};
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let previous_publication_status = existing_work
.as_ref()
.map(|work| work.publication_status.clone())
@@ -1889,6 +1891,32 @@ mod tests {
);
}
#[test]
fn match3d_draft_snapshot_keeps_generated_item_assets_json() {
let draft = Match3DDraftSnapshot {
profile_id: "profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string()],
clear_count: 3,
difficulty: 3,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"#
.to_string(),
),
};
let row_json = to_json_string(&draft);
let restored =
parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
assert_eq!(
restored.generated_item_assets_json.as_deref(),
draft.generated_item_assets_json.as_deref()
);
}
#[test]
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
let existing = Match3DWorkProfileRow {

View File

@@ -256,6 +256,8 @@ pub struct Match3DDraftSnapshot {
pub tags: Vec<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -899,7 +899,10 @@ fn compile_puzzle_agent_draft_tx(
}
let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text));
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text));
let draft = mark_puzzle_draft_generation_status(
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)),
"generating",
);
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
// 每次编译结果页时同步 upsert保证后续能按 source_session_id 恢复聊天。
upsert_puzzle_draft_work_profile(
@@ -2500,10 +2503,52 @@ fn profile_for_single_level(
level: &module_puzzle::PuzzleDraftLevel,
) -> PuzzleWorkProfile {
let mut next_profile = profile.clone();
let ui_background_carrier = profile.levels.iter().find(|candidate| {
candidate
.ui_background_image_src
.as_deref()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
|| candidate
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
});
let mut single_level = level.clone();
if single_level
.ui_background_image_src
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
&& single_level
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
&& let Some(carrier) = ui_background_carrier
{
single_level.ui_background_image_src = carrier
.ui_background_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
single_level.ui_background_image_object_key = carrier
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_start_matches('/').to_string());
}
next_profile.level_name = level.level_name.clone();
next_profile.cover_image_src = level.cover_image_src.clone();
next_profile.cover_asset_id = level.cover_asset_id.clone();
next_profile.levels = vec![level.clone()];
next_profile.levels = vec![single_level];
next_profile
}
@@ -2524,6 +2569,17 @@ fn micros_to_millis(value: i64) -> u64 {
(value as u64).saturating_div(1_000)
}
fn mark_puzzle_draft_generation_status(
mut draft: PuzzleResultDraft,
generation_status: &str,
) -> PuzzleResultDraft {
draft.generation_status = generation_status.to_string();
for level in &mut draft.levels {
level.generation_status = generation_status.to_string();
}
draft
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
@@ -3494,6 +3550,37 @@ mod tests {
assert!(preview.publish_ready);
}
#[test]
fn puzzle_draft_generation_status_updates_all_levels() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.levels.push(module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
});
let draft = mark_puzzle_draft_generation_status(draft, "generating");
assert_eq!(draft.generation_status, "generating");
assert!(
draft
.levels
.iter()
.all(|level| level.generation_status == "generating")
);
}
#[test]
fn puzzle_generated_images_replace_existing_candidate() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));

View File

@@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now
&& row.subtitle == "分支叙事体验"
&& row.image_src == "/creation-type-references/visual-novel.webp"
&& row.visible
&& ((row.badge == "可创建" && row.open)
|| (row.badge == "敬请期待" && !row.open))
&& ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open))
&& row.sort_order == 60;
if !still_old_visible_default {
return;