This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -90,10 +90,13 @@ use crate::{
match3d::{
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail,
get_match3d_works, list_match3d_gallery, persist_match3d_generated_model,
publish_match3d_work, put_match3d_audio_assets, put_match3d_work,
restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -951,6 +954,33 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/cover-image",
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/background-image",
post(generate_match3d_background_image_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/generated-models",
post(persist_match3d_generated_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(

View File

@@ -36,10 +36,12 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
"match3d_cover_image",
"match3d_item_image",
"square_hole_cover_image",
"square_hole_background_image",
"square_hole_shape_image",
@@ -765,6 +767,8 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_item_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"
));
@@ -786,7 +790,7 @@ mod tests {
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、match3d_cover_image、match3d_item_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,9 +64,10 @@ use spacetime_client::{
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
@@ -80,7 +81,10 @@ use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
openai_image_generation::VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
openai_image_generation::{
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::puzzle::{
draft::{
@@ -100,6 +104,9 @@ 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},
};
@@ -119,6 +126,10 @@ 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_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -229,6 +240,9 @@ pub async fn generate_puzzle_onboarding_work(
level_name: level_name.clone(),
picture_description: prompt_text.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
@@ -991,6 +1005,117 @@ pub async fn execute_puzzle_agent_action(
session,
)
}
"generate_puzzle_ui_background" => {
let target_level_id = payload.level_id.clone();
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
"puzzle_ui_background_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let resolved_prompt = normalize_puzzle_ui_background_prompt(
raw_prompt.as_str(),
&draft,
&target_level,
);
let generated = generate_puzzle_ui_background_image(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let save_result = state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
resolved_prompt,
generated.image_src,
Some(generated.object_key),
now,
))
}
Err(error) => Err(map_puzzle_client_error(error)),
}
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_ui_background",
"UI 背景图生成",
"已生成拼图 UI 背景图。",
session,
)
}
"generate_puzzle_tags" => {
let work_title = payload
.work_title
@@ -2061,6 +2186,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
ui_background_prompt: level.ui_background_prompt,
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),
candidates: level
.candidates
@@ -2377,6 +2505,8 @@ fn map_puzzle_runtime_level_response(
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
@@ -2667,6 +2797,9 @@ fn parse_puzzle_level_records_from_module_json(
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
ui_background_prompt: level.ui_background_prompt,
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_domain_record),
candidates: level
.candidates
@@ -2835,6 +2968,9 @@ fn serialize_puzzle_levels_response(
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
@@ -2884,6 +3020,9 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result<Option
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
@@ -3174,6 +3313,10 @@ fn build_puzzle_levels_with_primary_update(
.or_else(|| (!levels.is_empty()).then_some(0))
{
levels[index].level_name = target_level.level_name.clone();
levels[index].ui_background_prompt = target_level.ui_background_prompt.clone();
levels[index].ui_background_image_src = target_level.ui_background_image_src.clone();
levels[index].ui_background_image_object_key =
target_level.ui_background_image_object_key.clone();
if let Some(picture_reference) = picture_reference
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -3184,6 +3327,135 @@ fn build_puzzle_levels_with_primary_update(
levels
}
fn resolve_puzzle_background_music_title(
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()
}
fn normalize_puzzle_ui_background_prompt(
raw_prompt: &str,
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> String {
let prompt = raw_prompt.trim();
if !prompt.is_empty() {
return prompt.chars().take(420).collect();
}
let title = draft.work_title.trim();
let title = if title.is_empty() {
target_level.level_name.trim()
} else {
title
};
let tags = draft
.theme_tags
.iter()
.map(|tag| tag.trim())
.filter(|tag| !tag.is_empty())
.collect::<Vec<_>>()
.join("");
[
title,
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
.chars()
.take(420)
.collect()
}
fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str) -> String {
let level_name = level_name.trim();
let title_clause = if level_name.is_empty() {
String::new()
} else {
format!("当前拼图关卡名称:{level_name}")
};
format!(
"{title_clause}{prompt}\n严格参考输入图的构图关系:生成一张 9:16 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印。"
)
}
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,
});
}
async fn try_generate_puzzle_background_music(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
title: &str,
) -> Option<CreationAudioAsset> {
let normalized_title = title.trim();
if normalized_title.is_empty() {
return None;
}
match 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
{
Ok(music) => Some(music),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
profile_id,
error = %error,
"拼图草稿背景音乐生成失败,保留草稿并允许结果页重试"
);
None
}
}
}
async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
@@ -3249,8 +3521,27 @@ async fn compile_puzzle_draft_with_initial_cover(
target_level.level_name = refined_level_name;
}
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);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
&updated_levels,
)?);
let candidates_json = serde_json::to_string(
&candidates
@@ -3270,7 +3561,7 @@ async fn compile_puzzle_draft_with_initial_cover(
session_id: compiled_session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -3288,11 +3579,15 @@ async fn compile_puzzle_draft_with_initial_cover(
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
apply_generated_puzzle_levels_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
updated_levels.clone(),
now,
),
target_level.level_id.as_str(),
@@ -3400,8 +3695,27 @@ async fn compile_puzzle_draft_with_uploaded_cover(
target_level.level_name = refined_level_name;
}
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);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
&updated_levels,
)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
@@ -3438,7 +3752,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
session_id: compiled_session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -3455,11 +3769,15 @@ async fn compile_puzzle_draft_with_uploaded_cover(
"拼图上传图草稿回写不可用,降级返回本地快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
apply_generated_puzzle_levels_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
updated_levels.clone(),
now,
),
target_level.level_id.as_str(),
@@ -3552,6 +3870,23 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
session
}
fn apply_generated_puzzle_levels_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
levels: Vec<PuzzleDraftLevelRecord>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
if levels.is_empty() {
return session;
}
draft.levels = levels;
sync_puzzle_primary_draft_fields_from_level(draft);
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
@@ -3607,6 +3942,38 @@ fn replace_puzzle_session_draft_snapshot(
session
}
fn apply_generated_puzzle_ui_background_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
prompt: String,
image_src: String,
image_object_key: Option<String>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
let Some(target_index) = draft
.levels
.iter()
.position(|level| level.level_id == target_level_id)
.or_else(|| (!draft.levels.is_empty()).then_some(0))
else {
return session;
};
let level = &mut draft.levels[target_index];
level.ui_background_prompt = Some(prompt);
level.ui_background_image_src = Some(image_src);
level.ui_background_image_object_key = image_object_key;
if target_index == 0 {
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.progress_percent = session.progress_percent.max(96);
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
async fn generate_puzzle_work_tags(
state: &AppState,
work_title: &str,
@@ -3877,6 +4244,9 @@ fn serialize_puzzle_level_records_for_module(
"level_name": level.level_name,
"picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
"candidates": level
.candidates
@@ -4305,6 +4675,72 @@ async fn generate_puzzle_image_candidates(
Ok(items)
}
async fn generate_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
"9:16",
1,
&[reference_image],
"拼图 UI 背景图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 UI 背景图生成失败:未返回图片",
}))
})?;
persist_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
image,
)
.await
}
#[cfg(test)]
fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt: &str) -> String {
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(
json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图 UI 背景参考图为空",
}),
));
}
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4583,6 +5019,9 @@ mod tests {
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: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
@@ -4635,6 +5074,71 @@ mod tests {
);
}
#[test]
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
ui_background_image_src: Some(
"/generated-puzzle-assets/session/ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/ui/background.png".to_string(),
),
background_music: None,
candidates: vec![],
selected_candidate_id: None,
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 request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value =
serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["ui_background_prompt"],
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
);
assert!(payload[0].get("uiBackgroundPrompt").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
let prompt = build_puzzle_ui_background_request_prompt_for_test(
"雨夜猫街",
"雨夜猫街主题背景",
);
assert!(prompt.contains("9:16"));
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
assert!(prompt.contains("明确边界"));
assert!(prompt.contains("不要画文字"));
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
@@ -4673,6 +5177,9 @@ mod tests {
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![],
selected_candidate_id: None,
@@ -4846,6 +5353,11 @@ struct GeneratedPuzzleAssetResponse {
asset_id: String,
}
struct GeneratedPuzzleUiBackgroundResponse {
image_src: String,
object_key: String,
}
fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
@@ -5474,6 +5986,47 @@ async fn persist_puzzle_generated_asset(
})
}
async fn persist_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
task_id: &str,
image: DownloadedOpenAiImage,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::PuzzleAssets,
path_segments: vec![
sanitize_path_segment(session_id, "session"),
sanitize_path_segment(level_name, "puzzle"),
"ui-background".to_string(),
sanitize_path_segment(task_id, "task"),
],
file_name: format!("background.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id),
body: image.bytes,
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
Ok(GeneratedPuzzleUiBackgroundResponse {
image_src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
fn handle_puzzle_asset_spacetime_index_error(
error: SpacetimeClientError,
owner_user_id: &str,
@@ -5512,6 +6065,22 @@ fn build_puzzle_asset_metadata(
])
}
fn build_puzzle_ui_background_asset_metadata(
owner_user_id: &str,
session_id: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
(
"asset_kind".to_string(),
"puzzle_ui_background_image".to_string(),
),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
("entity_id".to_string(), session_id.to_string()),
("slot".to_string(), "ui_background".to_string()),
])
}
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({

View File

@@ -16,8 +16,9 @@ use serde_json::{Map, Value, json};
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
api_response::json_success_body, asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken, http_error::AppError, platform_errors::map_oss_error,
request_context::RequestContext, state::AppState,
};
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
@@ -36,6 +37,7 @@ const SUNO_TAGS_MAX_CHARS: usize = 160;
const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
const CREATION_AUDIO_POINTS_COST: u64 = 10;
#[derive(Clone, Debug)]
struct VectorEngineAudioSettings {
@@ -62,6 +64,16 @@ struct AudioAssetBindingTarget {
storage_scope: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCreationAudioTarget {
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub profile_id: Option<String>,
pub storage_prefix: LegacyAssetPrefix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioAssetSlot {
BackgroundMusic,
@@ -167,7 +179,6 @@ pub async fn create_background_music_task(
let Json(payload) = parse_json_payload(&request_context, payload)?;
create_background_music_task_response(
&state,
&request_context,
payload.prompt,
payload.title,
payload.tags,
@@ -240,9 +251,113 @@ pub async fn create_sound_effect_task(
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub(crate) async fn generate_sound_effect_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let task = create_sound_effect_task_response(
state,
normalized_prompt.clone(),
duration,
seed,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("音效生成完成但缺少播放地址")
})?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
pub(crate) async fn generate_background_music_asset_for_creation(
state: &AppState,
owner_user_id: &str,
prompt: String,
title: String,
tags: Option<String>,
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text_allow_empty(
&prompt,
"prompt",
SUNO_PROMPT_MAX_CHARS,
)?;
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")
})?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}
async fn create_background_music_task_response(
state: &AppState,
_request_context: &RequestContext,
prompt: String,
title: String,
tags: Option<String>,
@@ -250,7 +365,7 @@ async fn create_background_music_task_response(
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = build_vector_engine_audio_http_client(&settings)?;
let prompt = normalize_limited_text(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let prompt = normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let tags = tags
.as_deref()
@@ -264,6 +379,7 @@ async fn create_background_music_task_response(
("mv".to_string(), Value::String(model)),
("title".to_string(), Value::String(title)),
("task".to_string(), Value::String("generate".to_string())),
("make_instrumental".to_string(), Value::Bool(true)),
]);
if let Some(tags) = tags {
body.insert("tags".to_string(), Value::String(tags));
@@ -505,15 +621,27 @@ async fn publish_generated_audio_asset(
.into_iter()
.next()
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
let persisted = persist_generated_audio_asset(
let billing_asset_kind = target.asset_kind.clone();
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
let persisted = execute_billable_asset_operation_with_cost(
state,
&http_client,
owner_user_id,
&task_id,
slot,
target.clone(),
audio,
billing_asset_kind.as_str(),
billing_asset_id.as_str(),
CREATION_AUDIO_POINTS_COST,
async {
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
persist_generated_audio_asset(
state,
&http_client,
owner_user_id,
&task_id,
slot,
target.clone(),
audio,
)
.await
},
)
.await?;
@@ -528,6 +656,54 @@ async fn publish_generated_audio_asset(
})
}
async fn wait_for_generated_audio_asset(
state: &AppState,
owner_user_id: &str,
task_id: String,
slot: AudioAssetSlot,
target: AudioAssetBindingTarget,
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
let mut latest_status = String::new();
for _ in 0..40 {
let response = publish_generated_audio_asset(
state,
owner_user_id,
task_id.clone(),
slot,
target.clone(),
)
.await?;
if response.audio_src.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
return Ok(response);
}
latest_status = response.status;
tokio::time::sleep(Duration::from_millis(3_000)).await;
}
Err(vector_engine_bad_gateway(format!(
"音频生成超时:{}",
if latest_status.trim().is_empty() {
task_id
} else {
latest_status
}
)))
}
fn build_audio_billing_asset_id(
task_id: &str,
slot: AudioAssetSlot,
target: &AudioAssetBindingTarget,
) -> String {
format!(
"creation-audio:{}:{}:{}:{}",
slot.file_stem(),
task_id,
target.entity_id,
target.slot
)
}
async fn fetch_audio_task_payload(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
@@ -1068,6 +1244,24 @@ fn normalize_limited_text(
Ok(normalized)
}
fn normalize_limited_text_allow_empty(
value: &str,
field: &'static str,
max_chars: usize,
) -> Result<String, AppError> {
let normalized = value.trim().to_string();
if normalized.chars().count() > max_chars {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"field": field,
"message": format!("{field} 超过 {} 字符", max_chars),
})),
);
}
Ok(normalized)
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@@ -1166,6 +1360,11 @@ fn current_utc_micros() -> i64 {
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
fn current_utc_iso_text() -> String {
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
}
fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",

View File

@@ -140,6 +140,27 @@ pub fn start_run_with_seed_at(
config: &Match3DCreatorConfig,
seed: u64,
started_at_ms: u64,
) -> Result<Match3DRunSnapshot, Match3DFieldError> {
start_run_with_seed_at_and_item_type_count(
run_id,
owner_user_id,
profile_id,
config,
seed,
started_at_ms,
None,
)
}
/// 用确定性 seed 生成单局初始快照,可为试玩传入降档后的物品种类数量。
pub fn start_run_with_seed_at_and_item_type_count(
run_id: String,
owner_user_id: String,
profile_id: String,
config: &Match3DCreatorConfig,
seed: u64,
started_at_ms: u64,
item_type_count_override: Option<u32>,
) -> Result<Match3DRunSnapshot, Match3DFieldError> {
let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?;
let owner_user_id =
@@ -147,8 +168,9 @@ pub fn start_run_with_seed_at(
let profile_id =
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
let total_item_count = config
.clear_count
let clear_count =
normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty);
let total_item_count = clear_count
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
.ok_or(Match3DFieldError::InvalidClearCount)?;
let mut run = Match3DRunSnapshot {
@@ -159,15 +181,16 @@ pub fn start_run_with_seed_at(
started_at_ms,
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
clear_count: config.clear_count,
clear_count,
total_item_count,
cleared_item_count: 0,
board_version: 1,
items: build_initial_items(
config.clear_count,
clear_count,
config.difficulty,
seed,
&config.theme_text,
item_type_count_override,
),
tray_slots: empty_tray_slots(),
failure_reason: None,
@@ -306,11 +329,12 @@ fn build_initial_items(
difficulty: u32,
seed: u64,
theme_text: &str,
item_type_count_override: Option<u32>,
) -> Vec<Match3DItemSnapshot> {
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
let base_radius = resolve_item_radius(difficulty);
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count);
let item_type_count = resolve_item_type_count(clear_count);
let item_type_count = resolve_item_type_count(clear_count, difficulty, item_type_count_override);
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, item_type_count);
let size_tier_plan = resolve_size_tier_plan(item_type_count);
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
@@ -380,16 +404,50 @@ fn resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
.collect()
}
fn resolve_item_type_count(clear_count: u32) -> usize {
clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
pub fn resolve_match3d_item_type_count_for_difficulty(clear_count: u32, difficulty: u32) -> u32 {
let target = match clear_count {
8 => 3,
12 => 9,
16 => 15,
20 | 21 => 21,
_ => match difficulty {
0..=2 => 3,
3..=4 => 9,
5..=6 => 15,
_ => 21,
},
};
target.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT)
}
pub fn normalize_match3d_runtime_clear_count(clear_count: u32, difficulty: u32) -> u32 {
// 中文注释:旧硬核草稿曾保存 clear_count=20新硬核固定 21 种物品,
// 运行态也升到 21 组三消,避免出现 20 组却要求 21 种素材的不可达状态。
if clear_count == 20 && difficulty >= 7 {
21
} else {
clear_count
}
}
fn resolve_item_type_count(
clear_count: u32,
difficulty: u32,
item_type_count_override: Option<u32>,
) -> usize {
item_type_count_override
.filter(|count| *count > 0)
.unwrap_or_else(|| resolve_match3d_item_type_count_for_difficulty(clear_count, difficulty))
.min(clear_count.max(1))
.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
}
fn select_visual_keys(
rng: &mut DeterministicRng,
_theme_text: &str,
clear_count: u32,
item_type_count: usize,
) -> Vec<&'static str> {
let item_type_count = resolve_item_type_count(clear_count);
let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec();
// 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。
for index in (1..visual_keys.len()).rev() {
@@ -660,7 +718,7 @@ mod tests {
}
#[test]
fn item_type_count_follows_clear_count_until_twenty_five() {
fn item_type_count_follows_difficulty_config() {
let run = start_run_with_seed_at(
"run-types-small".to_string(),
"user-1".to_string(),
@@ -676,19 +734,36 @@ mod tests {
*counts.entry(item.item_type_id.clone()).or_default() += 1;
}
assert_eq!(counts.len(), 12);
assert!(counts.values().all(|count| *count == 3));
assert_eq!(counts.len(), 9);
assert!(counts.values().all(|count| *count % 3 == 0));
}
#[test]
fn visual_key_count_follows_fifteen_clear_count() {
fn visual_key_count_follows_override_for_test_run() {
let run = start_run_with_seed_at(
"run-types-default".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(16),
42,
1_000,
)
.expect("run should start");
let mut counts = BTreeMap::<String, u32>::new();
for item in &run.items {
*counts.entry(item.visual_key.clone()).or_default() += 1;
}
assert_eq!(counts.len(), 15);
let run = start_run_with_seed_at_and_item_type_count(
"run-types-fifteen".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(15),
42,
1_000,
Some(3),
)
.expect("run should start");
@@ -702,8 +777,8 @@ mod tests {
.push(item.item_type_id.clone());
}
assert_eq!(counts.len(), 15);
assert!(counts.values().all(|count| *count == 3));
assert_eq!(counts.len(), 3);
assert!(counts.values().all(|count| *count == 15));
assert!(item_types_by_visual_key.values().all(|item_type_ids| {
item_type_ids
.iter()
@@ -712,7 +787,7 @@ mod tests {
}
#[test]
fn item_type_count_is_capped_at_twenty_five() {
fn item_type_count_is_capped_at_runtime_limit() {
let run = start_run_with_seed_at(
"run-types-large".to_string(),
"user-1".to_string(),
@@ -728,10 +803,33 @@ mod tests {
*counts.entry(item.item_type_id.clone()).or_default() += 1;
}
assert_eq!(counts.len(), 25);
assert_eq!(counts.len(), 9);
assert!(counts.values().all(|count| count % 3 == 0));
}
#[test]
fn legacy_hardcore_clear_count_runs_as_twenty_one_groups() {
let run = start_run_with_seed_at(
"run-types-legacy-hardcore".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&build_creator_config("水果", None, 20, 8).expect("config should be valid"),
42,
1_000,
)
.expect("run should start");
let mut counts = BTreeMap::<String, u32>::new();
for item in &run.items {
*counts.entry(item.item_type_id.clone()).or_default() += 1;
}
assert_eq!(run.clear_count, 21);
assert_eq!(run.total_item_count, 63);
assert_eq!(counts.len(), 21);
assert!(counts.values().all(|count| *count == 3));
}
#[test]
fn initial_run_uses_slightly_different_item_sizes() {
let run = start_run_with_seed_at(
@@ -780,13 +878,14 @@ mod tests {
#[test]
fn same_visual_key_keeps_one_size_in_run() {
let run = start_run_with_seed_at(
let run = start_run_with_seed_at_and_item_type_count(
"run-size-unique".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(30),
42,
1_000,
Some(25),
)
.expect("run should start");
@@ -846,13 +945,14 @@ mod tests {
#[test]
fn twenty_five_or_less_does_not_repeat_visual_keys() {
let run = start_run_with_seed_at(
let run = start_run_with_seed_at_and_item_type_count(
"run-block-unique".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(25),
27,
1_000,
Some(25),
)
.expect("run should start");

View File

@@ -186,6 +186,9 @@ pub fn compile_result_draft_from_seed(
level_name: level_name.clone(),
picture_description,
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,
@@ -243,6 +246,9 @@ pub fn build_form_draft_from_parts(
level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(),
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,
@@ -349,6 +355,9 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
&draft.summary,
),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: draft.candidates.clone(),
selected_candidate_id: draft.selected_candidate_id.clone(),
@@ -436,6 +445,9 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft
),
picture_description,
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,
@@ -767,6 +779,13 @@ pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 {
}
}
fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.next()
}
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)
@@ -1027,6 +1046,7 @@ pub fn start_run_with_shuffle_seed_at(
let level_config = resolve_puzzle_level_config(level_index);
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);
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -1038,9 +1058,8 @@ pub fn start_run_with_shuffle_seed_at(
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id,
level_index,
level_id: entry_profile
.levels
.first()
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: entry_profile.profile_id.clone(),
@@ -1048,6 +1067,12 @@ 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()),
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
@@ -1297,6 +1322,7 @@ pub fn advance_next_level_at(
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
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);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1309,9 +1335,8 @@ pub fn advance_next_level_at(
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: next_level_index,
level_id: next_profile
.levels
.first()
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size: next_grid_size,
profile_id: next_profile.profile_id.clone(),
@@ -1319,6 +1344,12 @@ 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()),
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
@@ -1370,6 +1401,7 @@ pub fn advance_to_new_work_first_level_at(
if !played_profile_ids.contains(&next_profile.profile_id) {
played_profile_ids.push(next_profile.profile_id.clone());
}
let current_profile_level = first_profile_level(next_profile);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1382,9 +1414,8 @@ pub fn advance_to_new_work_first_level_at(
current_level: Some(PuzzleRuntimeLevelSnapshot {
run_id: run.run_id.clone(),
level_index: next_level_index,
level_id: next_profile
.levels
.first()
level_id: current_profile_level
.as_ref()
.map(|level| level.level_id.clone()),
grid_size,
profile_id: next_profile.profile_id.clone(),
@@ -1392,6 +1423,12 @@ 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()),
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
@@ -2802,6 +2839,9 @@ mod tests {
level_name: format!("{profile_id} 关"),
picture_description: "summary".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,
@@ -3017,6 +3057,9 @@ mod tests {
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,
@@ -3029,6 +3072,9 @@ mod tests {
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,
@@ -3072,6 +3118,30 @@ mod tests {
assert_eq!(next_level.time_limit_ms, 210_000);
}
#[test]
fn start_run_carries_first_level_background_music() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].background_music = Some(PuzzleAudioAsset {
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/background.mp3".to_string(),
prompt: Some(String::new()),
title: Some("奇境初见".to_string()),
updated_at: Some("2026-05-12T00:00:00Z".to_string()),
});
let run = start_run("run-music".to_string(), &profile, 0).expect("run");
assert_eq!(
run.current_level
.and_then(|level| level.background_music)
.map(|music| music.audio_src),
Some("/generated-puzzle-assets/background.mp3".to_string())
);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);

View File

@@ -79,6 +79,19 @@ pub struct PuzzleGeneratedImagesSaveInput {
pub saved_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleUiBackgroundSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub level_id: Option<String>,
pub levels_json: Option<String>,
pub prompt: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub saved_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleSelectCoverImageInput {

View File

@@ -169,6 +169,9 @@ pub fn build_puzzle_draft_from_creative_fields(
.unwrap_or_else(|| format!("{}", index + 1)),
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
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,

View File

@@ -132,6 +132,12 @@ pub struct PuzzleDraftLevel {
#[serde(default)]
pub picture_reference: Option<String>,
#[serde(default)]
pub ui_background_prompt: Option<String>,
#[serde(default)]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub ui_background_image_object_key: Option<String>,
#[serde(default)]
pub background_music: Option<PuzzleAudioAsset>,
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
pub selected_candidate_id: Option<String>,
@@ -356,6 +362,10 @@ pub struct PuzzleRuntimeLevelSnapshot {
pub author_display_name: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
#[serde(default)]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub background_music: Option<PuzzleAudioAsset>,
pub board: PuzzleBoardSnapshot,
pub status: PuzzleRuntimeLevelStatus,
#[serde(default)]

View File

@@ -21,6 +21,8 @@ pub struct CreateMatch3DAgentSessionRequest {
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
#[serde(default)]
pub generate_click_sound: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -50,6 +52,8 @@ pub struct ExecuteMatch3DAgentActionRequest {
pub clear_count: Option<u32>,
#[serde(default)]
pub difficulty: Option<u32>,
#[serde(default)]
pub generate_click_sound: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -66,6 +70,8 @@ pub struct Match3DCreatorConfigResponse {
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
#[serde(default)]
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -87,10 +93,42 @@ pub struct Match3DResultDraftResponse {
pub total_item_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image_object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_background_asset: Option<Match3DGeneratedBackgroundAssetResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedBackgroundAssetResponse {
pub prompt: String,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemImageViewResponse {
pub view_id: String,
pub view_index: u32,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemAssetResponse {
@@ -100,6 +138,8 @@ pub struct Match3DGeneratedItemAssetResponse {
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_views: Vec<Match3DGeneratedItemImageViewResponse>,
#[serde(default)]
pub model_src: Option<String>,
#[serde(default)]
@@ -111,9 +151,19 @@ pub struct Match3DGeneratedItemAssetResponse {
#[serde(default)]
pub subscription_key: Option<String>,
#[serde(default)]
pub sound_prompt: Option<String>,
#[serde(default)]
pub background_music_title: Option<String>,
#[serde(default)]
pub background_music_style: Option<String>,
#[serde(default)]
pub background_music_prompt: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
#[serde(default)]
pub click_sound: Option<CreationAudioAsset>,
#[serde(default)]
pub background_asset: Option<Match3DGeneratedBackgroundAssetResponse>,
pub status: String,
#[serde(default)]
pub error: Option<String>,
@@ -191,9 +241,10 @@ mod tests {
reference_image_src: Some("data:image/png;base64,abc".to_string()),
clear_count: Some(4),
difficulty: Some(3),
asset_style_id: Some("clay-toy".to_string()),
asset_style_label: Some("黏土手作".to_string()),
asset_style_prompt: Some("圆润黏土手作风".to_string()),
asset_style_id: Some("flat-icon".to_string()),
asset_style_label: Some("扁平图标".to_string()),
asset_style_prompt: Some("干净扁平 2D 游戏道具图标风格".to_string()),
generate_click_sound: Some(true),
})
.expect("payload should serialize");
@@ -204,6 +255,25 @@ mod tests {
json!("data:image/png;base64,abc")
);
assert_eq!(payload["clearCount"], json!(4));
assert_eq!(payload["assetStyleId"], json!("clay-toy"));
assert_eq!(payload["assetStyleId"], json!("flat-icon"));
assert_eq!(payload["generateClickSound"], json!(true));
}
#[test]
fn execute_match3d_action_request_serializes_click_sound_toggle() {
let payload = serde_json::to_value(ExecuteMatch3DAgentActionRequest {
action: "match3d_compile_draft".to_string(),
game_name: None,
summary: None,
tags: None,
cover_image_src: None,
clear_count: None,
difficulty: None,
generate_click_sound: Some(true),
})
.expect("payload should serialize");
assert_eq!(payload["action"], json!("match3d_compile_draft"));
assert_eq!(payload["generateClickSound"], json!(true));
}
}

View File

@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "camelCase")]
pub struct StartMatch3DRunRequest {
pub profile_id: String,
#[serde(default)]
pub item_type_count_override: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -24,6 +24,72 @@ pub struct PutMatch3DAudioAssetsRequest {
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PersistMatch3DGeneratedModelRequest {
pub item_id: String,
pub item_name: String,
pub source_url: String,
#[serde(default)]
pub file_name: Option<String>,
#[serde(default)]
pub task_uuid: Option<String>,
#[serde(default)]
pub subscription_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PersistMatch3DGeneratedModelResponse {
pub asset: Match3DGeneratedItemAssetResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DCoverImageRequest {
pub prompt: String,
#[serde(default)]
pub reference_image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DCoverImageResponse {
pub item: Match3DWorkProfileResponse,
pub cover_image_src: String,
pub cover_image_object_key: String,
pub prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DBackgroundImageRequest {
pub prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DBackgroundImageResponse {
pub item: Match3DWorkProfileResponse,
pub background_image_src: String,
pub background_image_object_key: String,
pub generated_background_asset: Match3DGeneratedBackgroundAssetResponse,
pub prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DItemAssetsRequest {
pub item_names: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DItemAssetsResponse {
pub item: Match3DWorkProfileResponse,
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DWorkSummaryResponse {
@@ -48,10 +114,42 @@ pub struct Match3DWorkSummaryResponse {
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image_object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_background_asset: Option<Match3DGeneratedBackgroundAssetResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generated_item_assets: Vec<Match3DGeneratedItemAssetResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedBackgroundAssetResponse {
pub prompt: String,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemImageViewResponse {
pub view_id: String,
pub view_index: u32,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DGeneratedItemAssetResponse {
@@ -61,6 +159,8 @@ pub struct Match3DGeneratedItemAssetResponse {
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_views: Vec<Match3DGeneratedItemImageViewResponse>,
#[serde(default)]
pub model_src: Option<String>,
#[serde(default)]
@@ -72,9 +172,19 @@ pub struct Match3DGeneratedItemAssetResponse {
#[serde(default)]
pub subscription_key: Option<String>,
#[serde(default)]
pub sound_prompt: Option<String>,
#[serde(default)]
pub background_music_title: Option<String>,
#[serde(default)]
pub background_music_style: Option<String>,
#[serde(default)]
pub background_music_prompt: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
#[serde(default)]
pub click_sound: Option<CreationAudioAsset>,
#[serde(default)]
pub background_asset: Option<Match3DGeneratedBackgroundAssetResponse>,
pub status: String,
#[serde(default)]
pub error: Option<String>,

View File

@@ -154,6 +154,12 @@ pub struct PuzzleDraftLevelResponse {
#[serde(default)]
pub picture_reference: Option<String>,
#[serde(default)]
pub ui_background_prompt: Option<String>,
#[serde(default)]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub ui_background_image_object_key: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
pub candidates: Vec<PuzzleGeneratedImageCandidateResponse>,
#[serde(default)]

View File

@@ -1,3 +1,4 @@
use crate::creation_audio::CreationAudioAsset;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -116,6 +117,10 @@ pub struct PuzzleRuntimeLevelSnapshotResponse {
pub theme_tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub background_music: Option<CreationAudioAsset>,
pub board: PuzzleBoardSnapshotResponse,
pub status: String,
#[serde(default)]

View File

@@ -50,8 +50,9 @@ pub use mapper::{
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentMessageRecord,
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,

View File

@@ -2917,6 +2917,9 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle
level_name: snapshot.level_name,
picture_description: snapshot.picture_description,
picture_reference: snapshot.picture_reference,
ui_background_prompt: snapshot.ui_background_prompt,
ui_background_image_src: snapshot.ui_background_image_src,
ui_background_image_object_key: snapshot.ui_background_image_object_key,
background_music: snapshot.background_music.map(map_puzzle_audio_asset),
candidates: snapshot
.candidates
@@ -3022,6 +3025,7 @@ fn map_match3d_creator_config(
asset_style_id: snapshot.asset_style_id,
asset_style_label: snapshot.asset_style_label,
asset_style_prompt: snapshot.asset_style_prompt,
generate_click_sound: snapshot.generate_click_sound,
}
}
@@ -3799,6 +3803,8 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
author_display_name: snapshot.author_display_name,
theme_tags: snapshot.theme_tags,
cover_image_src: snapshot.cover_image_src,
ui_background_image_src: snapshot.ui_background_image_src,
background_music: snapshot.background_music.map(map_puzzle_audio_asset),
board: map_puzzle_board_snapshot(snapshot.board),
status: snapshot.status.as_str().to_string(),
started_at_ms,
@@ -5888,6 +5894,18 @@ pub struct PuzzleGeneratedImagesSaveRecordInput {
pub saved_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleUiBackgroundSaveRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub level_id: Option<String>,
pub levels_json: Option<String>,
pub prompt: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub saved_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleSelectCoverImageRecordInput {
pub session_id: String,
@@ -6110,6 +6128,7 @@ pub struct Match3DRunStartRecordInput {
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
pub item_type_count_override: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6168,6 +6187,7 @@ pub struct Match3DCreatorConfigRecord {
pub asset_style_id: Option<String>,
pub asset_style_label: Option<String>,
pub asset_style_prompt: Option<String>,
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6301,6 +6321,8 @@ struct Match3DCreatorConfigJsonRecord {
asset_style_label: Option<String>,
#[serde(default)]
asset_style_prompt: Option<String>,
#[serde(default)]
generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -7285,6 +7307,9 @@ pub struct PuzzleDraftLevelRecord {
pub level_name: String,
pub picture_description: String,
pub picture_reference: Option<String>,
pub ui_background_prompt: Option<String>,
pub ui_background_image_src: Option<String>,
pub ui_background_image_object_key: Option<String>,
pub background_music: Option<PuzzleAudioAssetRecord>,
pub candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
pub selected_candidate_id: Option<String>,
@@ -7459,6 +7484,8 @@ pub struct PuzzleRuntimeLevelRecord {
pub author_display_name: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
pub background_music: Option<PuzzleAudioAssetRecord>,
pub board: PuzzleBoardRecord,
pub status: String,
pub started_at_ms: u64,

View File

@@ -296,6 +296,7 @@ impl SpacetimeClient {
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
started_at_ms: input.started_at_ms,
item_type_count_override: input.item_type_count_override,
};
self.call_after_connect(move |connection, sender| {

View File

@@ -11,6 +11,7 @@ pub struct Match3DRunStartInput {
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
pub item_type_count_override: u32,
}
impl __sdk::InModule for Match3DRunStartInput {

View File

@@ -493,6 +493,7 @@ pub mod puzzle_run_swap_input_type;
pub mod puzzle_runtime_run_row_type;
pub mod puzzle_runtime_run_table;
pub mod puzzle_select_cover_image_input_type;
pub mod puzzle_ui_background_save_input_type;
pub mod puzzle_work_delete_input_type;
pub mod puzzle_work_get_input_type;
pub mod puzzle_work_like_record_input_type;
@@ -679,6 +680,7 @@ pub mod runtime_tracking_event_procedure_result_type;
pub mod runtime_tracking_scope_kind_type;
pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure;
pub mod save_puzzle_ui_background_procedure;
pub mod seed_analytics_date_dimensions_reducer;
pub mod select_puzzle_cover_image_procedure;
pub mod square_hole_agent_message_finalize_input_type;
@@ -1301,6 +1303,7 @@ pub use puzzle_run_swap_input_type::PuzzleRunSwapInput;
pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow;
pub use puzzle_runtime_run_table::*;
pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput;
pub use puzzle_ui_background_save_input_type::PuzzleUiBackgroundSaveInput;
pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput;
pub use puzzle_work_get_input_type::PuzzleWorkGetInput;
pub use puzzle_work_like_record_input_type::PuzzleWorkLikeRecordInput;
@@ -1487,6 +1490,7 @@ pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProced
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
pub use save_puzzle_ui_background_procedure::save_puzzle_ui_background;
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image;
pub use square_hole_agent_message_finalize_input_type::SquareHoleAgentMessageFinalizeInput;

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PuzzleUiBackgroundSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub level_id: Option<String>,
pub levels_json: Option<String>,
pub prompt: String,
pub image_src: String,
pub image_object_key: Option<String>,
pub saved_at_micros: i64,
}
impl __sdk::InModule for PuzzleUiBackgroundSaveInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult;
use super::puzzle_ui_background_save_input_type::PuzzleUiBackgroundSaveInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct SavePuzzleUiBackgroundArgs {
pub input: PuzzleUiBackgroundSaveInput,
}
impl __sdk::InModule for SavePuzzleUiBackgroundArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `save_puzzle_ui_background`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait save_puzzle_ui_background {
fn save_puzzle_ui_background(&self, input: PuzzleUiBackgroundSaveInput) {
self.save_puzzle_ui_background_then(input, |_, _| {});
}
fn save_puzzle_ui_background_then(
&self,
input: PuzzleUiBackgroundSaveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl save_puzzle_ui_background for super::RemoteProcedures {
fn save_puzzle_ui_background_then(
&self,
input: PuzzleUiBackgroundSaveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleAgentSessionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>(
"save_puzzle_ui_background",
SavePuzzleUiBackgroundArgs { input },
__callback,
);
}
}

View File

@@ -4,6 +4,7 @@ use crate::module_bindings::claim_puzzle_work_point_incentive_procedure::claim_p
use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work;
use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like;
use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work;
use crate::module_bindings::save_puzzle_ui_background_procedure::save_puzzle_ui_background;
impl SpacetimeClient {
pub async fn create_puzzle_agent_session(
@@ -190,6 +191,35 @@ impl SpacetimeClient {
.await
}
pub async fn save_puzzle_ui_background(
&self,
input: PuzzleUiBackgroundSaveRecordInput,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let procedure_input = PuzzleUiBackgroundSaveInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
level_id: input.level_id,
levels_json: input.levels_json,
prompt: input.prompt,
image_src: input.image_src,
image_object_key: input.image_object_key,
saved_at_micros: input.saved_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().save_puzzle_ui_background_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_puzzle_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn select_puzzle_cover_image(
&self,
input: PuzzleSelectCoverImageRecordInput,

View File

@@ -13,13 +13,12 @@ use module_match3d::{
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at_and_item_type_count,
stop_run_at as stop_domain_run_at,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
use serde_json::Value;
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
@@ -722,7 +721,12 @@ fn start_match3d_run_tx(
} else {
current_server_ms(ctx)
};
let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms);
let mut snapshot = build_initial_run_snapshot(
&input.run_id,
&work,
started_at_ms,
normalize_match3d_item_type_count_override(input.item_type_count_override),
);
snapshot.server_now_ms = current_server_ms(ctx);
snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms);
let now = ctx.timestamp;
@@ -838,6 +842,7 @@ fn restart_match3d_run_tx(
input: Match3DRunRestartInput,
) -> Result<Match3DRunSnapshot, String> {
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
let item_type_count_override = resolve_item_type_count_override_from_run(&source);
start_match3d_run_tx(
ctx,
Match3DRunStartInput {
@@ -845,6 +850,7 @@ fn restart_match3d_run_tx(
owner_user_id: input.owner_user_id,
profile_id: source.profile_id,
started_at_ms: input.restarted_at_ms,
item_type_count_override,
},
)
}
@@ -992,19 +998,25 @@ fn build_initial_run_snapshot(
run_id: &str,
work: &Match3DWorkProfileRow,
started_at_ms: i64,
item_type_count_override: Option<u32>,
) -> Match3DRunSnapshot {
let config = parse_config_or_default(&work.config_json);
let domain_config =
let mut domain_config =
domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config());
domain_config.clear_count = module_match3d::normalize_match3d_runtime_clear_count(
domain_config.clear_count,
domain_config.difficulty,
);
let domain_started_at_ms = to_u64_ms(started_at_ms);
let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty);
let domain_run = start_run_with_seed_at(
let domain_run = start_run_with_seed_at_and_item_type_count(
run_id.to_string(),
work.owner_user_id.clone(),
work.profile_id.clone(),
&domain_config,
seed,
domain_started_at_ms,
item_type_count_override,
)
.unwrap_or_else(|_| DomainMatch3DRunSnapshot {
run_id: run_id.to_string(),
@@ -1026,6 +1038,26 @@ fn build_initial_run_snapshot(
snapshot_from_domain(&domain_run, started_at_ms)
}
fn normalize_match3d_item_type_count_override(value: u32) -> Option<u32> {
(value > 0).then_some(value)
}
fn resolve_item_type_count_override_from_run(row: &Match3DRuntimeRunRow) -> u32 {
deserialize_snapshot(&row.snapshot_json)
.ok()
.map(|snapshot| {
let mut item_type_ids = snapshot
.items
.iter()
.map(|item| item.item_type_id.clone())
.collect::<Vec<_>>();
item_type_ids.sort();
item_type_ids.dedup();
item_type_ids.len() as u32
})
.unwrap_or(0)
}
fn fallback_domain_config() -> DomainMatch3DCreatorConfig {
DomainMatch3DCreatorConfig {
theme_text: "经典消除".to_string(),
@@ -1218,7 +1250,19 @@ fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String>
if parse_tags(&row.tags_json)?.is_empty() {
return Err("match3d 发布需要至少 1 个标签".to_string());
}
validate_config(&parse_config(&row.config_json)?)
let config = parse_config(&row.config_json)?;
let required_item_types =
module_match3d::resolve_match3d_item_type_count_for_difficulty(
config.clear_count,
config.difficulty,
) as usize;
let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
if ready_item_types < required_item_types {
return Err(format!(
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types}"
));
}
validate_config(&config)
}
fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool {
@@ -1234,6 +1278,7 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
@@ -1255,10 +1300,8 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
}
fn normalize_match3d_generated_item_config(
mut config: Match3DCreatorConfigSnapshot,
config: Match3DCreatorConfigSnapshot,
) -> Match3DCreatorConfigSnapshot {
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
config
}
@@ -1284,6 +1327,59 @@ fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<St
Ok(Some(to_json_string(&parsed)))
}
fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String> {
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(0);
};
let parsed = parse_json::<Vec<Value>>(trimmed, "match3d generated_item_assets_json")?;
Ok(parsed
.iter()
.filter(|asset| {
let status_ready = asset
.get("status")
.and_then(Value::as_str)
.map(|status| matches!(status, "image_ready" | "model_ready"))
.unwrap_or(false);
let view_count = asset
.get("imageViews")
.or_else(|| asset.get("image_views"))
.and_then(Value::as_array)
.map(|views| {
views
.iter()
.filter(|view| {
view.get("imageSrc")
.or_else(|| view.get("image_src"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
|| view
.get("imageObjectKey")
.or_else(|| view.get("image_object_key"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
.count()
})
.unwrap_or(0);
let has_primary_image = asset
.get("imageSrc")
.or_else(|| asset.get("image_src"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
|| asset
.get("imageObjectKey")
.or_else(|| asset.get("image_object_key"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
status_ready && (view_count >= 5 || has_primary_image)
})
.count())
}
fn resolve_generated_item_assets_json_for_compile(
input: Option<&str>,
existing_work: Option<&Match3DWorkProfileRow>,
@@ -1695,6 +1791,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1702,7 +1799,7 @@ mod tests {
published_at: None,
generated_item_assets_json: None,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
let snapshot = build_initial_run_snapshot("run-1", &work, 10, None);
assert_eq!(snapshot.total_item_count, 12);
assert_eq!(snapshot.items.len(), 12);
}
@@ -1730,6 +1827,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1774,6 +1872,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1817,6 +1916,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1846,7 +1946,7 @@ mod tests {
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
fn match3d_compile_keeps_difficulty_clear_count() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
@@ -1855,10 +1955,12 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: true,
});
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
assert_eq!(config.clear_count, 20);
assert_eq!(config.difficulty, 8);
assert!(config.generate_click_sound);
}
#[test]

View File

@@ -138,6 +138,7 @@ pub struct Match3DRunStartInput {
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
pub item_type_count_override: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -230,6 +231,8 @@ pub struct Match3DCreatorConfigSnapshot {
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
#[serde(default)]
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -15,7 +15,8 @@ use module_puzzle::{
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput,
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
@@ -313,6 +314,25 @@ pub fn save_puzzle_generated_images(
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_ui_background(
ctx: &mut ProcedureContext,
input: PuzzleUiBackgroundSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn select_puzzle_cover_image(
ctx: &mut ProcedureContext,
@@ -1025,6 +1045,70 @@ fn save_puzzle_generated_images_tx(
)
}
fn save_puzzle_ui_background_tx(
ctx: &TxContext,
input: PuzzleUiBackgroundSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
draft.levels = levels;
module_puzzle::sync_primary_level_fields(&mut draft);
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
next_level.ui_background_image_object_key = input
.image_object_key
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent.max(96),
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图 UI 背景图已生成。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn sync_generated_primary_level_name_as_default_work_title(
draft: &mut PuzzleResultDraft,
previous_work_title: &str,
@@ -1069,6 +1153,9 @@ fn select_puzzle_cover_image_tx(
level_name: target_level.level_name,
picture_description: target_level.picture_description,
picture_reference: target_level.picture_reference,
ui_background_prompt: target_level.ui_background_prompt,
ui_background_image_src: target_level.ui_background_image_src,
ui_background_image_object_key: target_level.ui_background_image_object_key,
background_music: target_level.background_music,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
@@ -2322,6 +2409,9 @@ fn build_profile_levels_from_row(
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
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,
@@ -3337,6 +3427,9 @@ mod tests {
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: candidates.clone(),
selected_candidate_id: None,