Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -105,9 +105,6 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -127,9 +124,8 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -201,13 +197,14 @@ pub async fn generate_puzzle_onboarding_work(
let now = current_utc_micros();
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
let level_name = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags = generate_puzzle_work_tags(&state, level_name.as_str(), prompt_text.as_str()).await;
let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags =
generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await;
let candidates = generate_puzzle_image_candidates(
&state,
"onboarding-guest",
session_id.as_str(),
level_name.as_str(),
naming.level_name.as_str(),
prompt_text.as_str(),
None,
false,
@@ -236,10 +233,10 @@ pub async fn generate_puzzle_onboarding_work(
})?;
let level = PuzzleDraftLevelRecord {
level_id: "onboarding-level-1".to_string(),
level_name: level_name.clone(),
level_name: naming.level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_prompt: naming.ui_background_prompt.clone(),
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
@@ -250,7 +247,7 @@ pub async fn generate_puzzle_onboarding_work(
generation_status: "ready".to_string(),
};
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
level_name.as_str(),
naming.level_name.as_str(),
level.picture_description.as_str(),
));
let item = PuzzleWorkProfileRecord {
@@ -259,9 +256,9 @@ pub async fn generate_puzzle_onboarding_work(
owner_user_id: "onboarding-guest".to_string(),
source_session_id: None,
author_display_name: "陶泥儿主".to_string(),
work_title: level_name.clone(),
work_title: naming.level_name.clone(),
work_description: prompt_text.clone(),
level_name,
level_name: naming.level_name,
summary: prompt_text,
theme_tags: tags,
cover_image_src: level.cover_image_src.clone(),
@@ -919,14 +916,17 @@ pub async fn execute_puzzle_agent_action(
}),
));
}
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
&state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
target_level.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
let generated_level_name = target_level.level_name.clone();
let levels_json_with_generated_name =
@@ -2396,7 +2396,11 @@ fn map_puzzle_work_summary_response(
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
levels: Vec::new(),
levels: item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
}
}
@@ -2404,15 +2408,8 @@ fn map_puzzle_work_profile_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary,
summary: map_puzzle_work_summary_response(state, item.clone()),
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
}
}
@@ -2507,6 +2504,7 @@ fn map_puzzle_runtime_level_response(
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
@@ -3066,7 +3064,25 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
)
}
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
#[derive(Clone, Debug, Eq, PartialEq)]
struct PuzzleLevelNaming {
level_name: String,
ui_background_prompt: Option<String>,
}
impl PuzzleLevelNaming {
fn fallback(picture_description: &str) -> Self {
Self {
level_name: build_fallback_puzzle_first_level_name(picture_description),
ui_background_prompt: None,
}
}
}
async fn generate_puzzle_first_level_name(
state: &AppState,
picture_description: &str,
) -> PuzzleLevelNaming {
if let Some(llm_client) = state.llm_client() {
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
let response = llm_client
@@ -3081,10 +3097,9 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
.await;
match response {
Ok(response) => {
if let Some(level_name) =
parse_puzzle_first_level_name_from_text(response.content.as_str())
if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str())
{
return level_name;
return naming;
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -3103,14 +3118,14 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
}
}
build_fallback_puzzle_first_level_name(picture_description)
PuzzleLevelNaming::fallback(picture_description)
}
async fn generate_puzzle_first_level_name_from_image(
state: &AppState,
picture_description: &str,
image: &PuzzleDownloadedImage,
) -> Option<String> {
) -> Option<PuzzleLevelNaming> {
let Some(llm_client) = state.creative_agent_gpt5_client() else {
return None;
};
@@ -3141,7 +3156,7 @@ async fn generate_puzzle_first_level_name_from_image(
match response {
Ok(response) => {
parse_puzzle_first_level_name_from_text(response.content.as_str()).or_else(|| {
parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL,
@@ -3191,7 +3206,7 @@ fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
Some(cursor.into_inner())
}
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming> {
let trimmed = text.trim();
let json_text = if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
@@ -3211,7 +3226,66 @@ fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
.and_then(|value| value.get("level_name").and_then(Value::as_str))
})
.unwrap_or(trimmed);
normalize_puzzle_first_level_name(raw_name)
let level_name = normalize_puzzle_first_level_name(raw_name)?;
let ui_background_prompt = parsed
.as_ref()
.and_then(parse_puzzle_ui_background_prompt_field);
Some(PuzzleLevelNaming {
level_name,
ui_background_prompt,
})
}
#[cfg(test)]
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name)
}
fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
value
.get("uiBackgroundPrompt")
.and_then(Value::as_str)
.or_else(|| value.get("ui_background_prompt").and_then(Value::as_str))
.and_then(normalize_puzzle_generated_ui_background_prompt)
}
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
let normalized = value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.split_whitespace()
.collect::<Vec<_>>()
.join("");
let filtered = normalized
.replace("拼图槽", "")
.replace("棋盘", "")
.replace("HUD", "")
.replace("按钮", "")
.replace("文字", "")
.replace("水印", "")
.replace("数字", "")
.replace("拼图碎片", "")
.replace("完整拼图图像", "")
.replace("教程浮层", "");
let prompt = filtered
.chars()
.take(160)
.collect::<String>()
.trim()
.trim_matches(|ch: char| matches!(ch, '' | '。' | '、' | '' | ''))
.to_string();
if prompt.chars().count() >= 12 {
Some(prompt)
} else {
None
}
}
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
@@ -3332,15 +3406,15 @@ fn build_puzzle_levels_with_primary_update(
levels
}
fn resolve_puzzle_background_music_title(
fn resolve_puzzle_initial_ui_background_prompt(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> String {
let work_title = draft.work_title.trim();
if !work_title.is_empty() {
return work_title.to_string();
}
target_level.level_name.trim().to_string()
target_level
.ui_background_prompt
.as_deref()
.and_then(normalize_puzzle_generated_ui_background_prompt)
.unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level))
}
fn normalize_puzzle_ui_background_prompt(
@@ -3371,7 +3445,7 @@ fn normalize_puzzle_ui_background_prompt(
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER,
]
.into_iter()
.filter(|value| !value.is_empty())
@@ -3394,30 +3468,6 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
)
}
fn attach_puzzle_level_background_music(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
music: CreationAudioAsset,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
levels[index].background_music = Some(PuzzleAudioAssetRecord {
task_id: music.task_id,
provider: music.provider,
asset_object_id: music.asset_object_id,
asset_kind: music.asset_kind,
audio_src: music.audio_src,
prompt: music.prompt,
title: music.title,
updated_at: music.updated_at,
});
}
fn attach_puzzle_level_ui_background(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
@@ -3436,38 +3486,6 @@ fn attach_puzzle_level_ui_background(
levels[index].ui_background_image_object_key = Some(generated.object_key);
}
async fn generate_puzzle_background_music_required(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
title: &str,
) -> Result<CreationAudioAsset, AppError> {
let normalized_title = title.trim();
if normalized_title.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
})));
}
generate_background_music_asset_for_creation(
state,
owner_user_id,
String::new(),
normalized_title.to_string(),
Some("轻快, 拼图, 循环, instrumental".to_string()),
None,
GeneratedCreationAudioTarget {
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
entity_id: profile_id.to_string(),
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
},
)
.await
}
async fn generate_puzzle_initial_ui_background_required(
state: &AppState,
owner_user_id: &str,
@@ -3475,7 +3493,7 @@ async fn generate_puzzle_initial_ui_background_required(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
let generated = generate_puzzle_ui_background_image(
state,
owner_user_id,
@@ -3490,11 +3508,6 @@ async fn generate_puzzle_initial_ui_background_required(
fn ensure_puzzle_initial_level_assets_ready(
level: &PuzzleDraftLevelRecord,
) -> Result<(), AppError> {
let has_background_music = level
.background_music
.as_ref()
.map(|music| music.audio_src.trim())
.is_some_and(|value| !value.is_empty());
let has_ui_background = level
.ui_background_image_src
.as_deref()
@@ -3505,23 +3518,22 @@ fn ensure_puzzle_initial_level_assets_ready(
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if has_background_music && has_ui_background {
if has_ui_background {
return Ok(());
}
let mut missing = Vec::new();
if !has_background_music {
missing.push("背景音乐");
}
if !has_ui_background {
missing.push("UI背景图");
}
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("")),
"missingAssets": missing,
})))
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("")),
"missingAssets": missing,
})),
)
}
fn find_puzzle_level_for_initial_asset_check<'a>(
@@ -3582,9 +3594,9 @@ async fn compile_puzzle_draft_with_initial_cover(
1,
target_level.candidates.len(),
);
let (generated_level_name, candidates_result) =
tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_level_name.clone();
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_naming.level_name.clone();
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
let candidates = candidates_result?;
let selected_candidate_id = candidates
.iter()
@@ -3597,21 +3609,22 @@ async fn compile_puzzle_draft_with_initial_cover(
"message": "拼图候选图生成结果为空",
}))
})?;
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
target_level.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
let generated_level_name = target_level.level_name.clone();
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
// 中文注释UI 背景先生成,避免其失败后留下已经扣费但未写入草稿的音乐资产。
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
@@ -3626,17 +3639,6 @@ async fn compile_puzzle_draft_with_initial_cover(
ui_prompt,
ui_background,
);
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
generate_puzzle_background_music_required(
state,
owner_user_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await?,
);
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -3809,19 +3811,24 @@ async fn compile_puzzle_draft_with_uploaded_cover(
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let (generated_level_name, refined_level_name, persisted_upload_result) = tokio::join!(
let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!(
level_name_future,
image_level_name_future,
persist_upload_future
);
target_level.level_name = refined_level_name.unwrap_or(generated_level_name.clone());
if let Some(refined_naming) = refined_naming {
generated_naming.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
}
}
target_level.level_name = generated_naming.level_name;
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
let generated_level_name = target_level.level_name.clone();
let persisted_upload = persisted_upload_result?;
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
// 中文注释:直用上传图时同样先补 UI 背景,再生成会单独扣费的音乐资产。
// 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
@@ -3836,17 +3843,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
ui_prompt,
ui_background,
);
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
generate_puzzle_background_music_required(
state,
owner_user_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await?,
);
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -5061,6 +5057,39 @@ mod tests {
);
}
#[test]
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
)
.expect("naming should parse");
assert_eq!(naming.level_name, "雨夜猫街");
assert_eq!(
naming.ui_background_prompt.as_deref(),
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
);
}
#[test]
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印保留暖色灯光"}"#,
)
.expect("naming should parse");
let prompt = naming
.ui_background_prompt
.as_deref()
.expect("prompt should parse");
assert!(!prompt.contains("拼图槽"));
assert!(!prompt.contains("棋盘"));
assert!(!prompt.contains("HUD"));
assert!(!prompt.contains("按钮"));
assert!(!prompt.contains("文字"));
assert!(!prompt.contains("水印"));
}
#[test]
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
assert_eq!(
@@ -5256,6 +5285,74 @@ mod tests {
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let level = PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".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![PuzzleGeneratedImageCandidateRecord {
candidate_id: "candidate-1".to_string(),
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
asset_id: "asset-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: None,
source_type: "generated".to_string(),
selected: true,
}],
selected_candidate_id: Some("candidate-1".to_string()),
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let response = map_puzzle_work_summary_response(
&state,
PuzzleWorkProfileRecord {
work_id: "puzzle-work-1".to_string(),
profile_id: "puzzle-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("puzzle-session-1".to_string()),
author_display_name: "玩家".to_string(),
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "雨夜猫街".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec!["".to_string()],
cover_image_src: None,
cover_asset_id: None,
publication_status: "draft".to_string(),
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: false,
anchor_pack: test_puzzle_anchor_pack_record(),
levels: vec![level],
},
);
assert_eq!(response.levels.len(), 1);
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
);
assert_eq!(
response.levels[0].candidates[0].image_src,
"/generated-puzzle-assets/session/candidate-1.png"
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt =
@@ -5268,6 +5365,34 @@ mod tests {
assert!(prompt.contains("文字"));
}
#[test]
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
let mut draft = test_puzzle_draft_record();
draft.work_title = "模板作品名".to_string();
draft.work_description = "模板作品描述".to_string();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let ai_prompt =
"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
target_level.ui_background_prompt = Some(ai_prompt.to_string());
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert_eq!(prompt, ai_prompt);
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
let draft = test_puzzle_draft_record();
let target_level = draft.levels[0].clone();
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert!(prompt.contains("雨夜猫街"));
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
@@ -5299,33 +5424,17 @@ mod tests {
}
#[test]
fn puzzle_initial_draft_assets_must_include_music_and_ui_background() {
fn puzzle_initial_draft_assets_must_include_ui_background() {
let mut draft = test_puzzle_draft_record();
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
assert!(missing_all.body_text().contains("背景音乐"));
assert!(missing_all.body_text().contains("UI背景图"));
draft.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
let missing_music = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("只有 UI 背景时仍不能完成草稿");
assert!(missing_music.body_text().contains("背景音乐"));
draft.levels[0].background_music = Some(PuzzleAudioAssetRecord {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/session/music.mp3".to_string(),
prompt: Some(String::new()),
title: Some("雨夜猫街".to_string()),
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
});
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("音乐和 UI 背景存在时才能完成自动草稿");
.expect("UI 背景存在时即可完成自动草稿资源检查");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {