Files
Genarrative/server-rs/crates/api-server/src/match3d.rs
2026-05-14 14:21:17 +08:00

7192 lines
248 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
convert::Infallible,
future::Future,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::sleep;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::{StreamExt, stream::FuturesUnordered};
use image::{GenericImageView, ImageFormat};
use module_match3d::{
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
MATCH3D_SESSION_ID_PREFIX,
};
use platform_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
hyper3d as hyper3d_contract,
match3d_agent::{
CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest,
Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse,
Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse,
Match3DCreatorConfigResponse,
Match3DGeneratedItemAssetResponse as Match3DAgentGeneratedItemAssetResponse,
Match3DResultDraftResponse, SendMatch3DAgentMessageRequest,
},
match3d_runtime::{
ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse,
Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse,
Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest,
},
match3d_works::{
GenerateMatch3DBackgroundImageRequest, GenerateMatch3DBackgroundImageResponse,
GenerateMatch3DCoverImageRequest, GenerateMatch3DCoverImageResponse,
GenerateMatch3DItemAssetsRequest, GenerateMatch3DItemAssetsResponse,
Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse,
Match3DWorkSummaryResponse, Match3DWorksResponse, PersistMatch3DGeneratedModelRequest,
PersistMatch3DGeneratedModelResponse, PutMatch3DAudioAssetsRequest, PutMatch3DWorkRequest,
},
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord,
Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput,
Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord,
Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord,
Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput,
Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput,
Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord,
Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::{
execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error,
should_skip_asset_operation_billing_for_connectivity,
},
auth::AuthenticatedAccessToken,
config::AppConfig,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiGeneratedImages, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
generate_sound_effect_asset_for_creation,
},
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
const MATCH3D_WORKS_PROVIDER: &str = "match3d-works";
const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具";
const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12;
const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10;
const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5;
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5;
const MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD: i32 = 36;
const MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD: i32 = 36;
const MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE: f32 = 0.34;
const MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
const MATCH3D_MATERIAL_APIMART_MODEL: &str = "gemini-3.1-flash-image-preview";
const MATCH3D_MATERIAL_APIMART_SIZE: &str = "1:1";
const MATCH3D_MATERIAL_APIMART_RESOLUTION: &str = "1K";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_LEGACY_MODEL_MAX_BYTES: usize = 120 * 1024 * 1024;
const MATCH3D_ITEM_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024;
const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str =
"public/match3d-background-references/pot-fused-reference.png";
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作的关卡是难度几";
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND: &str = "match3d_background_music";
const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound";
const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DConfigJson {
theme_text: String,
reference_image_src: Option<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
asset_style_id: Option<String>,
#[serde(default)]
asset_style_label: Option<String>,
#[serde(default)]
asset_style_prompt: Option<String>,
#[serde(default)]
generate_click_sound: bool,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemAsset {
item_id: String,
item_name: String,
image_src: Option<String>,
image_object_key: Option<String>,
image_views: Vec<Match3DGeneratedItemImageView>,
model_src: Option<String>,
model_object_key: Option<String>,
model_file_name: Option<String>,
task_uuid: Option<String>,
subscription_key: Option<String>,
sound_prompt: Option<String>,
background_music_title: Option<String>,
background_music_style: Option<String>,
background_music_prompt: Option<String>,
background_music: Option<CreationAudioAsset>,
click_sound: Option<CreationAudioAsset>,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
status: String,
error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemImageView {
view_id: String,
view_index: u32,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedBackgroundAsset {
prompt: String,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
container_prompt: Option<String>,
#[serde(default)]
container_image_src: Option<String>,
#[serde(default)]
container_image_object_key: Option<String>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedWorkMetadata {
game_name: String,
summary: String,
tags: Vec<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemPlan {
name: String,
sound_prompt: String,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedBackgroundMusicPlan {
title: String,
style: String,
prompt: String,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedDraftPlan {
metadata: Match3DGeneratedWorkMetadata,
items: Vec<Match3DGeneratedItemPlan>,
background_music: Match3DGeneratedBackgroundMusicPlan,
background_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemAssetJson {
item_id: String,
item_name: String,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
image_views: Vec<Match3DGeneratedItemImageView>,
#[serde(default)]
model_src: Option<String>,
#[serde(default)]
model_object_key: Option<String>,
#[serde(default)]
model_file_name: Option<String>,
#[serde(default)]
task_uuid: Option<String>,
#[serde(default)]
subscription_key: Option<String>,
#[serde(default)]
sound_prompt: Option<String>,
#[serde(default)]
background_music_title: Option<String>,
#[serde(default)]
background_music_style: Option<String>,
#[serde(default)]
background_music_prompt: Option<String>,
#[serde(default)]
background_music: Option<CreationAudioAsset>,
#[serde(default)]
click_sound: Option<CreationAudioAsset>,
#[serde(default)]
background_asset: Option<Match3DGeneratedBackgroundAsset>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DAssetUpload {
src: String,
object_key: String,
}
struct Match3DDownloadedModel {
bytes: Vec<u8>,
file_name: String,
content_type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CompileMatch3DDraftRequest {
#[serde(default)]
game_name: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
#[serde(default)]
cover_image_src: Option<String>,
#[serde(default)]
generate_click_sound: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsRequest {
game_name: String,
theme_text: String,
#[serde(default)]
summary: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsResponse {
tags: Vec<String>,
}
struct Match3DWorkAssetContext {
owner_user_id: String,
session_id: String,
profile: Match3DWorkProfileRecord,
config: Match3DConfigJson,
assets: Vec<Match3DGeneratedItemAsset>,
}
struct Match3DItemAssetAppendPlan {
requested_item_names: Vec<String>,
padded_item_names: Vec<String>,
}
pub async fn create_match3d_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CreateMatch3DAgentSessionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
let config = build_config_from_create_request(&payload);
let seed_text = build_seed_text(&payload, &config);
let welcome_message_text = MATCH3D_QUESTION_THEME.to_string();
let session = state
.spacetime_client()
.create_match3d_agent_session(Match3DAgentSessionCreateRecordInput {
session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
seed_text,
welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX),
welcome_message_text,
config_json: serialize_match3d_config(&config),
created_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DAgentSessionResponse {
session: map_match3d_agent_session_response(session),
},
))
}
pub async fn get_match3d_agent_session(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
let session = state
.spacetime_client()
.get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DAgentSessionResponse {
session: map_match3d_agent_session_response(session),
},
))
}
pub async fn submit_match3d_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
let session = submit_and_finalize_match3d_message(
&state,
&request_context,
authenticated.claims().user_id(),
session_id,
payload,
)
.await?;
Ok(json_success_body(
Some(&request_context),
Match3DAgentSessionResponse {
session: map_match3d_agent_session_response(session),
},
))
}
pub async fn stream_match3d_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SendMatch3DAgentMessageRequest>, JsonRejection>,
) -> Result<Response, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let request_context_for_stream = request_context.clone();
let stream = async_stream::stream! {
let result = submit_and_finalize_match3d_message(
&state,
&request_context_for_stream,
owner_user_id.as_str(),
session_id,
payload,
)
.await;
match result {
Ok(session) => {
let session_response = map_match3d_agent_session_response(session);
if let Some(reply) = session_response.last_assistant_reply.clone() {
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"reply_delta",
json!({ "text": reply }),
));
}
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
}
Err(response) => {
yield Ok::<Event, Infallible>(match3d_sse_json_event_or_error(
"error",
json!({ "message": response.status().to_string() }),
));
}
}
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_match3d_agent_action(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ExecuteMatch3DAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
if payload.action.trim() != "match3d_compile_draft" {
return Err(match3d_bad_request(
&request_context,
MATCH3D_AGENT_PROVIDER,
"unknown match3d action",
));
}
let (session, generated_item_assets) = compile_match3d_draft_for_session(
&state,
&request_context,
&authenticated,
session_id,
payload.game_name,
payload.summary,
payload.tags,
payload.cover_image_src,
payload.generate_click_sound,
)
.await?;
Ok(json_success_body(
Some(&request_context),
Match3DAgentActionResponse {
session: map_match3d_agent_session_response_with_assets(
session,
&generated_item_assets,
),
},
))
}
pub async fn compile_match3d_agent_draft(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CompileMatch3DDraftRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = payload
.map(|Json(payload)| payload)
.unwrap_or(CompileMatch3DDraftRequest {
game_name: None,
summary: None,
tags: None,
cover_image_src: None,
generate_click_sound: None,
});
ensure_non_empty(
&request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
let (session, generated_item_assets) = compile_match3d_draft_for_session(
&state,
&request_context,
&authenticated,
session_id,
payload.game_name,
payload.summary,
payload.tags,
payload.cover_image_src,
payload.generate_click_sound,
)
.await?;
Ok(json_success_body(
Some(&request_context),
Match3DAgentActionResponse {
session: map_match3d_agent_session_response_with_assets(
session,
&generated_item_assets,
),
},
))
}
pub async fn get_match3d_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_match3d_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorksResponse {
items: items
.into_iter()
.map(map_match3d_work_summary_response)
.collect(),
},
))
}
pub async fn list_match3d_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_match3d_gallery()
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorksResponse {
items: items
.into_iter()
.map(map_match3d_work_summary_response)
.collect(),
},
))
}
pub async fn get_match3d_work_detail(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorkDetailResponse {
item: map_match3d_work_profile_response(item),
},
))
}
pub async fn put_match3d_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutMatch3DWorkRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let existing = state
.spacetime_client()
.get_match3d_work_detail(
profile_id.clone(),
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let theme_text = payload
.theme_text
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or(existing.theme_text);
let item = state
.spacetime_client()
.update_match3d_work(Match3DWorkUpdateRecordInput {
profile_id,
owner_user_id: authenticated.claims().user_id().to_string(),
game_name: payload.game_name,
theme_text,
summary_text: payload.summary,
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
cover_image_src: payload.cover_image_src.unwrap_or_default(),
cover_asset_id: String::new(),
clear_count: payload.clear_count,
difficulty: payload.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorkMutationResponse {
item: map_match3d_work_profile_response(item),
},
))
}
pub async fn put_match3d_audio_assets(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PutMatch3DAudioAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let existing = state
.spacetime_client()
.get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let session_id = existing.source_session_id.clone().ok_or_else(|| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅作品缺少来源 session无法写回音频素材",
})),
)
})?;
let assets = payload
.generated_item_assets
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let session = upsert_match3d_draft_snapshot(
&state,
&request_context,
&authenticated,
session_id,
owner_user_id.clone(),
profile_id.clone(),
Some(existing.game_name),
Some(existing.summary),
Some(serde_json::to_string(&existing.tags).unwrap_or_default()),
existing.cover_image_src,
None,
serialize_match3d_generated_item_assets(&assets),
)
.await?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id, owner_user_id)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let _ = session;
Ok(json_success_body(
Some(&request_context),
Match3DWorkMutationResponse {
item: map_match3d_work_profile_response(item),
},
))
}
pub async fn persist_match3d_generated_model(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PersistMatch3DGeneratedModelRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&payload.item_id,
"itemId",
)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&payload.item_name,
"itemName",
)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&payload.source_url,
"sourceUrl",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let existing = state
.spacetime_client()
.get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let session_id = existing.source_session_id.clone().ok_or_else(|| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅作品缺少来源 session无法保存历史模型",
})),
)
})?;
let mut assets =
parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let current_asset = assets
.iter()
.find(|asset| asset.item_id == payload.item_id)
.cloned();
let item_name = normalize_match3d_item_name(payload.item_name.as_str());
let item_name = if item_name.is_empty() {
current_asset
.as_ref()
.map(|asset| asset.item_name.clone())
.unwrap_or_else(|| payload.item_name.trim().to_string())
} else {
item_name
};
let model_file = hyper3d_contract::Hyper3dDownloadFilePayload {
name: normalize_optional_text(payload.file_name.as_deref())
.unwrap_or_else(|| "model.glb".to_string()),
url: payload.source_url.trim().to_string(),
};
let downloaded_model = download_match3d_legacy_model(&model_file)
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
let task_uuid = normalize_optional_text(payload.task_uuid.as_deref());
let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str());
let generated_at_micros = current_utc_micros();
let uploaded_model = persist_match3d_generated_bytes(
&state,
owner_user_id.as_str(),
session_id.as_str(),
profile_id.as_str(),
&[
"items",
item_slug.as_str(),
"model",
task_uuid.as_deref().unwrap_or("manual"),
],
downloaded_model.file_name.as_str(),
downloaded_model.content_type.as_str(),
downloaded_model.bytes,
"match3d_item_model",
task_uuid.as_deref(),
generated_at_micros,
)
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
let next_asset = Match3DGeneratedItemAsset {
item_id: payload.item_id,
item_name,
image_src: current_asset
.as_ref()
.and_then(|asset| asset.image_src.clone()),
image_object_key: current_asset
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
image_views: current_asset
.as_ref()
.map(|asset| asset.image_views.clone())
.unwrap_or_default(),
model_src: Some(uploaded_model.src),
model_object_key: Some(uploaded_model.object_key),
model_file_name: Some(downloaded_model.file_name),
task_uuid,
subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else(
|| {
current_asset
.as_ref()
.and_then(|asset| asset.subscription_key.clone())
},
),
sound_prompt: current_asset
.as_ref()
.and_then(|asset| asset.sound_prompt.clone()),
background_music_title: current_asset
.as_ref()
.and_then(|asset| asset.background_music_title.clone()),
background_music_style: current_asset
.as_ref()
.and_then(|asset| asset.background_music_style.clone()),
background_music_prompt: current_asset
.as_ref()
.and_then(|asset| asset.background_music_prompt.clone()),
background_music: current_asset
.as_ref()
.and_then(|asset| asset.background_music.clone()),
click_sound: current_asset
.as_ref()
.and_then(|asset| asset.click_sound.clone()),
background_asset: current_asset
.as_ref()
.and_then(|asset| asset.background_asset.clone()),
status: "model_ready".to_string(),
error: None,
};
upsert_match3d_generated_item_asset(&mut assets, next_asset.clone());
persist_match3d_generated_item_assets_snapshot(
&state,
&request_context,
&authenticated,
session_id.as_str(),
owner_user_id.as_str(),
profile_id.as_str(),
&assets,
)
.await?;
Ok(json_success_body(
Some(&request_context),
PersistMatch3DGeneratedModelResponse {
asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from(
next_asset,
)),
},
))
}
pub async fn generate_match3d_cover_image(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DCoverImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str());
ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?;
let context =
load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
.await?;
let generated_cover = generate_match3d_cover_image_asset(
&state,
&context.owner_user_id,
context.session_id.as_str(),
profile_id.as_str(),
&context.config,
prompt.as_str(),
payload.reference_image_src,
)
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
upsert_match3d_draft_snapshot(
&state,
&request_context,
&authenticated,
context.session_id.clone(),
context.owner_user_id.clone(),
profile_id.clone(),
Some(context.profile.game_name),
Some(context.profile.summary),
Some(serde_json::to_string(&context.profile.tags).unwrap_or_default()),
Some(generated_cover.src.clone()),
None,
None,
)
.await?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id.clone(), context.owner_user_id)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DCoverImageResponse {
item: map_match3d_work_profile_response(item),
cover_image_src: generated_cover.src,
cover_image_object_key: generated_cover.object_key,
prompt,
},
))
}
pub async fn generate_match3d_background_image_for_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DBackgroundImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let prompt = normalize_match3d_background_prompt(payload.prompt.as_str());
ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?;
let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str());
let context =
load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
.await?;
let billing_asset_id = format!(
"{}:{}:{}",
context.session_id, profile_id, prompt_fingerprint
);
let generated_background = execute_billable_asset_operation_with_cost(
&state,
context.owner_user_id.as_str(),
"match3d_ui_background_image",
billing_asset_id.as_str(),
MATCH3D_BACKGROUND_IMAGE_POINTS_COST,
async {
let generated_background = generate_match3d_background_image(
&state,
context.owner_user_id.as_str(),
context.session_id.as_str(),
profile_id.as_str(),
&context.config,
prompt.as_str(),
)
.await?;
let mut assets = context.assets;
attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone());
persist_match3d_generated_item_assets_snapshot(
&state,
&request_context,
&authenticated,
context.session_id.as_str(),
context.owner_user_id.as_str(),
profile_id.as_str(),
&assets,
)
.await
.map_err(|response| {
AppError::from_status(response.status()).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅 UI 背景图已生成但保存失败",
}))
})?;
Ok(generated_background)
},
)
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id, context.owner_user_id)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let background_image_src = generated_background.image_src.clone().unwrap_or_default();
let background_image_object_key = generated_background
.image_object_key
.clone()
.unwrap_or_default();
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DBackgroundImageResponse {
item: map_match3d_work_profile_response(item),
background_image_src,
background_image_object_key,
generated_background_asset: map_match3d_background_asset_for_work(generated_background),
prompt,
},
))
}
pub async fn generate_match3d_item_assets_for_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DItemAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item_names = normalize_match3d_batch_item_names(payload.item_names);
if item_names.is_empty() {
return Err(match3d_bad_request(
&request_context,
MATCH3D_WORKS_PROVIDER,
"请填写至少一个物品名称",
));
}
let context =
load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
.await?;
let Match3DWorkAssetContext {
owner_user_id,
session_id,
profile,
config,
assets,
} = context;
let append_plan = build_match3d_item_asset_append_plan(item_names, &assets);
if append_plan.requested_item_names.is_empty() {
return Ok(json_success_body(
Some(&request_context),
GenerateMatch3DItemAssetsResponse {
item: map_match3d_work_profile_response(profile),
generated_item_assets: sort_match3d_generated_assets(assets)
.into_iter()
.map(Match3DGeneratedItemAssetJson::from)
.map(map_match3d_generated_item_asset_for_work)
.collect(),
},
));
}
let billed_item_count = append_plan.requested_item_names.len();
let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count);
let billing_asset_id = format!(
"{}:{}:{}:{}",
session_id,
profile_id,
billed_item_count,
build_match3d_prompt_fingerprint(append_plan.requested_item_names.join("|").as_str())
);
let generated_assets = execute_billable_asset_operation_with_cost(
&state,
owner_user_id.as_str(),
"match3d_item_assets",
billing_asset_id.as_str(),
points_cost,
async {
append_match3d_item_assets(
&state,
&request_context,
&authenticated,
owner_user_id.as_str(),
session_id.as_str(),
profile_id.as_str(),
&config,
append_plan,
assets,
)
.await
.map_err(|response| {
AppError::from_status(response.status()).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅批量新增物品素材失败",
}))
})
},
)
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id, owner_user_id)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DItemAssetsResponse {
item: map_match3d_work_profile_response(item),
generated_item_assets: generated_assets
.into_iter()
.map(Match3DGeneratedItemAssetJson::from)
.map(map_match3d_generated_item_asset_for_work)
.collect(),
},
))
}
pub async fn generate_match3d_work_tags(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
let tags = generate_match3d_work_tags_for_profile(
&state,
payload.game_name.as_str(),
payload.theme_text.as_str(),
payload.summary.as_deref(),
)
.await;
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DWorkTagsResponse { tags },
))
}
pub async fn publish_match3d_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let item = state
.spacetime_client()
.publish_match3d_work(
profile_id,
authenticated.claims().user_id().to_string(),
current_utc_micros(),
)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorkMutationResponse {
item: map_match3d_work_profile_response(item),
},
))
}
pub async fn delete_match3d_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
MATCH3D_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let items = state
.spacetime_client()
.delete_match3d_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DWorksResponse {
items: items
.into_iter()
.map(map_match3d_work_summary_response)
.collect(),
},
))
}
pub async fn start_match3d_run(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<StartMatch3DRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let maybe_payload = payload.ok().map(|Json(payload)| payload);
let profile_id = maybe_payload
.as_ref()
.map(|payload| payload.profile_id.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or(profile_id);
ensure_non_empty(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
&profile_id,
"profileId",
)?;
let run = state
.spacetime_client()
.start_match3d_run(Match3DRunStartRecordInput {
run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: profile_id.clone(),
started_at_ms: current_utc_ms(),
item_type_count_override: maybe_payload
.as_ref()
.and_then(|payload| payload.item_type_count_override)
.unwrap_or(0),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"match3d",
profile_id.clone(),
&authenticated,
"/api/runtime/match3d/...",
)
.profile_id(profile_id.clone())
.extra(json!({
"runId": run.run_id,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {
run: map_match3d_run_response(run),
},
))
}
pub async fn get_match3d_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.get_match3d_run(run_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {
run: map_match3d_run_response(run),
},
))
}
pub async fn click_match3d_item(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<ClickMatch3DItemRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
&payload.item_instance_id,
"itemInstanceId",
)?;
ensure_non_empty(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
&payload.client_event_id,
"clientEventId",
)?;
let confirmation = state
.spacetime_client()
.click_match3d_item(Match3DRunClickRecordInput {
run_id: payload.run_id.unwrap_or(run_id),
owner_user_id: authenticated.claims().user_id().to_string(),
item_instance_id: payload.item_instance_id,
client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32,
client_event_id: payload.client_event_id,
clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64,
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DClickResponse {
confirmation: map_match3d_click_confirmation_response(confirmation),
},
))
}
pub async fn stop_match3d_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<StopMatch3DRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let _ = payload.ok();
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.stop_match3d_run(Match3DRunStopRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
stopped_at_ms: current_utc_ms(),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {
run: map_match3d_run_response(run),
},
))
}
pub async fn restart_match3d_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.restart_match3d_run(Match3DRunRestartRecordInput {
source_run_id: run_id,
next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
restarted_at_ms: current_utc_ms(),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {
run: map_match3d_run_response(run),
},
))
}
pub async fn finish_match3d_time_up(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.finish_match3d_time_up(Match3DRunTimeUpRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
finished_at_ms: current_utc_ms(),
})
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_RUNTIME_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {
run: map_match3d_run_response(run),
},
))
}
async fn submit_and_finalize_match3d_message(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: String,
payload: SendMatch3DAgentMessageRequest,
) -> Result<Match3DAgentSessionRecord, Response> {
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.client_message_id,
"clientMessageId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.text,
"text",
)?;
let submitted = state
.spacetime_client()
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.to_string(),
user_message_id: payload.client_message_id.clone(),
user_message_text: payload.text.clone(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let next_turn = submitted.current_turn.saturating_add(1);
let next_config = build_config_from_message(&submitted, &payload);
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
let progress_percent = resolve_progress_percent_for_turn(next_turn);
let stage = if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string();
state
.spacetime_client()
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
session_id,
owner_user_id: owner_user_id.to_string(),
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
assistant_reply_text: Some(assistant_reply),
config_json: serialize_match3d_config(&next_config),
progress_percent,
stage,
updated_at_micros: current_utc_micros(),
error_message: None,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
async fn compile_match3d_draft_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
game_name: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
cover_image_src: Option<String>,
generate_click_sound: Option<bool>,
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let initial_session = state
.spacetime_client()
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let mut config = resolve_config_or_default(initial_session.config.as_ref());
if let Some(generate_click_sound) = generate_click_sound {
config.generate_click_sound = generate_click_sound;
}
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
let has_complete_form_config = !config.theme_text.trim().is_empty()
&& config.clear_count > 0
&& (1..=10).contains(&config.difficulty);
if !has_complete_form_config
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
{
return Err(match3d_bad_request(
request_context,
MATCH3D_AGENT_PROVIDER,
"match3d 创作配置尚未确认完成",
));
}
let requested_game_name = normalize_optional_match3d_text(game_name);
let requested_summary = normalize_optional_match3d_text(summary);
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
let initial_game_name = requested_game_name
.clone()
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
let initial_tags = requested_tags
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
execute_billable_match3d_draft_generation(
state,
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
async {
let mut session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id.clone(),
owner_user_id.clone(),
profile_id.clone(),
Some(initial_game_name),
requested_summary.clone().or_else(|| Some(String::new())),
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
if session.draft.is_none() {
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
));
}
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
let resolved_game_name = requested_game_name
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
let resolved_summary = requested_summary
.clone()
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
let resolved_tags = match requested_tags {
Some(tags) => tags,
None => {
generate_match3d_work_tags_for_plan(
state,
resolved_game_name.as_str(),
config.theme_text.as_str(),
resolved_summary.as_str(),
&generated_work_metadata.metadata.tags,
)
.await
}
};
generated_work_metadata.metadata.tags = resolved_tags.clone();
session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id.clone(),
profile_id.clone(),
Some(resolved_game_name),
Some(resolved_summary),
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
let existing_assets = get_match3d_existing_generated_item_assets(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.items,
generated_work_metadata.background_music,
existing_assets,
)
.await?;
let generated_item_assets = ensure_match3d_background_asset(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
generated_item_assets,
)
.await?;
let existing_cover_image_src = get_match3d_existing_cover_image_src(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let default_cover_image_src = requested_cover_image_src
.clone()
.or(existing_cover_image_src)
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
let next_session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session.session_id.clone(),
owner_user_id.clone(),
profile_id,
None,
None,
None,
default_cover_image_src,
None,
serialize_match3d_generated_item_assets(&generated_item_assets),
)
.await?;
Ok((next_session, generated_item_assets))
},
)
.await
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
operation: Fut,
) -> Result<T, Response>
where
Fut: Future<Output = Result<T, Response>>,
{
let points_consumed = consume_match3d_draft_generation_points(
state,
request_context,
owner_user_id,
billing_asset_id,
)
.await?;
match operation.await {
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
}
Err(response)
}
}
}
async fn consume_match3d_draft_generation_points(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
match state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
Ok(_) => Ok(true),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
Err(error) => Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_asset_operation_wallet_error(error),
)),
}
}
async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
if let Err(error) = state
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
tracing::error!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿生成失败后的泥点退款失败"
);
}
}
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
session
.draft
.as_ref()
.map(|draft| draft.profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.or_else(|| {
session
.published_profile_id
.as_deref()
.map(str::trim)
.filter(|profile_id| !profile_id.is_empty())
})
.map(str::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
}
#[allow(clippy::too_many_arguments)]
async fn upsert_match3d_draft_snapshot(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
owner_user_id: String,
profile_id: String,
game_name: Option<String>,
summary_text: Option<String>,
tags_json: Option<String>,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
generated_item_assets_json: Option<String>,
) -> Result<Match3DAgentSessionRecord, Response> {
state
.spacetime_client()
.compile_match3d_draft(Match3DCompileDraftRecordInput {
session_id,
owner_user_id,
profile_id,
author_display_name: resolve_author_display_name(state, authenticated),
game_name,
summary_text,
tags_json,
cover_image_src,
cover_asset_id,
compiled_at_micros: current_utc_micros(),
generated_item_assets_json,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
async fn get_match3d_existing_generated_item_assets(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
) -> Vec<Match3DGeneratedItemAsset> {
match state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string())
.await
{
Ok(profile) => {
parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect()
}
Err(error) => {
tracing::debug!(
provider = MATCH3D_AGENT_PROVIDER,
profile_id,
error = %error,
"读取抓大鹅已有素材失败,按空素材继续生成"
);
Vec::new()
}
}
}
async fn get_match3d_existing_cover_image_src(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
) -> Option<String> {
state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string())
.await
.ok()
.and_then(|profile| profile.cover_image_src)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
async fn load_match3d_work_asset_context(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
profile_id: &str,
) -> Result<Match3DWorkAssetContext, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let profile = state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let session_id = profile.source_session_id.clone().ok_or_else(|| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅作品缺少来源 session无法生成素材",
})),
)
})?;
let config = match state
.spacetime_client()
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => {
let mut config = resolve_config_or_default(session.config.as_ref());
if config.theme_text.trim().is_empty() {
config.theme_text = profile.theme_text.clone();
}
config
}
Err(error) => {
tracing::debug!(
provider = MATCH3D_WORKS_PROVIDER,
profile_id,
session_id = session_id.as_str(),
error = %error,
"读取抓大鹅 session 配置失败,使用作品 profile 派生素材配置"
);
Match3DConfigJson {
theme_text: profile.theme_text.clone(),
reference_image_src: profile.reference_image_src.clone(),
clear_count: profile.clear_count,
difficulty: profile.difficulty,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
};
let assets = parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
Ok(Match3DWorkAssetContext {
owner_user_id,
session_id,
profile,
config,
assets,
})
}
#[allow(clippy::too_many_arguments)]
async fn persist_match3d_generated_item_assets_snapshot(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: &str,
owner_user_id: &str,
profile_id: &str,
assets: &[Match3DGeneratedItemAsset],
) -> Result<(), Response> {
upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id.to_string(),
owner_user_id.to_string(),
profile_id.to_string(),
None,
None,
None,
None,
None,
serialize_match3d_generated_item_assets(assets),
)
.await
.map(|_| ())
}
fn map_match3d_agent_session_response(
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
Match3DAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_match3d_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: session.config.map(map_match3d_config_response),
draft: session.draft.map(map_match3d_draft_response),
messages: session
.messages
.into_iter()
.map(map_match3d_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
fn map_match3d_agent_session_response_with_assets(
session: Match3DAgentSessionRecord,
generated_item_assets: &[Match3DGeneratedItemAsset],
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect();
if draft
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
}
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
}
response
}
fn map_match3d_anchor_pack_response_for_turn(
anchor: Match3DAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> Match3DAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "ReadyToPublish"
| "ready_to_publish"
| "Published"
| "published"
);
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
Match3DAnchorPackResponse {
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
clear_count: map_match3d_anchor_item_response_for_collected(
anchor.clear_count,
collected_count >= 2,
),
difficulty: map_match3d_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 3,
),
}
}
fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
fn map_match3d_anchor_item_response_for_collected(
anchor: Match3DAnchorItemRecord,
collected: bool,
) -> Match3DAnchorItemResponse {
if collected {
return map_match3d_anchor_item_response(anchor);
}
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
Match3DCreatorConfigResponse {
theme_text: config.theme_text,
reference_image_src: config.reference_image_src,
clear_count: config.clear_count,
difficulty: config.difficulty,
asset_style_id: config.asset_style_id,
asset_style_label: config.asset_style_label,
asset_style_prompt: config.asset_style_prompt,
generate_click_sound: config.generate_click_sound,
}
}
fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
summary_text: Some(draft.summary_text.clone()),
summary: draft.summary_text,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
reference_image_src: draft.reference_image_src,
clear_count: draft.clear_count,
difficulty: draft.difficulty,
total_item_count: draft.total_item_count,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
}
}
fn map_match3d_generated_item_asset_for_agent(
asset: Match3DGeneratedItemAsset,
) -> Match3DAgentGeneratedItemAssetResponse {
Match3DAgentGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_agent)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_agent),
status: asset.status,
error: asset.error,
}
}
fn map_match3d_generated_item_asset_for_work(
asset: Match3DGeneratedItemAssetJson,
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_work),
status: asset.status,
error: asset.error,
}
}
fn map_match3d_image_view_for_agent(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_image_view_for_work(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_image_view_from_work(
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
) -> Match3DGeneratedItemImageView {
Match3DGeneratedItemImageView {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
fn map_match3d_background_asset_for_work(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
fn find_match3d_generated_background_asset(
assets: &[Match3DGeneratedItemAsset],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
find_match3d_generated_background_asset(assets).and_then(|asset| {
asset
.container_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
})
}
fn find_match3d_generated_background_asset_json(
assets: &[Match3DGeneratedItemAssetJson],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
fn apply_match3d_background_asset_to_agent_draft(
draft: &mut Match3DResultDraftResponse,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
) {
if let Some(asset) = background_asset {
draft.background_prompt = Some(asset.prompt.clone());
draft.background_image_src = asset.image_src.clone();
draft.background_image_object_key = asset.image_object_key.clone();
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
}
}
fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
Match3DAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
let generated_item_assets = generated_item_asset_json
.into_iter()
.map(map_match3d_generated_item_asset_for_work)
.collect();
Match3DWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
reference_image_src: item.reference_image_src,
clear_count: item.clear_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()
.and_then(|asset| asset.image_src.clone()),
background_image_object_key: background_asset
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
generated_background_asset,
generated_item_assets,
}
}
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
}))
}
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
"missingAssets": ["背景音乐"],
}))
}
fn require_match3d_background_music_title(
plan: &Match3DGeneratedBackgroundMusicPlan,
) -> Result<String, AppError> {
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Err(match3d_background_music_missing_error(
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
));
}
Ok(title)
}
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
Match3DWorkProfileResponse {
summary: map_match3d_work_summary_response(item),
}
}
fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
Match3DRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
server_now_ms: run.server_now_ms,
remaining_ms: run.remaining_ms,
clear_count: run.clear_count,
total_item_count: run.total_item_count,
cleared_item_count: run.cleared_item_count,
items: run
.items
.into_iter()
.map(map_match3d_item_response)
.collect(),
tray_slots: run
.tray_slots
.into_iter()
.map(map_match3d_tray_slot_response)
.collect(),
failure_reason: run
.failure_reason
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
last_confirmed_action_id: run.last_confirmed_action_id,
}
}
fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
Match3DItemSnapshotResponse {
item_instance_id: item.item_instance_id,
item_type_id: item.item_type_id,
visual_key: item.visual_key,
x: item.x,
y: item.y,
radius: item.radius,
layer: item.layer,
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
clickable: item.clickable,
tray_slot_index: item.tray_slot_index,
}
}
fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
Match3DTraySlotResponse {
slot_index: slot.slot_index,
item_instance_id: slot.item_instance_id,
item_type_id: slot.item_type_id,
visual_key: slot.visual_key,
}
}
fn map_match3d_click_confirmation_response(
confirmation: Match3DClickConfirmationRecord,
) -> Match3DClickConfirmationResponse {
Match3DClickConfirmationResponse {
accepted: confirmation.accepted,
reject_reason: confirmation
.reject_reason
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
entered_slot_index: confirmation.entered_slot_index,
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
run: map_match3d_run_response(confirmation.run),
}
}
fn build_config_from_create_request(
payload: &CreateMatch3DAgentSessionRequest,
) -> Match3DConfigJson {
Match3DConfigJson {
theme_text: payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(MATCH3D_DEFAULT_THEME)
.to_string(),
reference_image_src: payload.reference_image_src.clone(),
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
difficulty: payload
.difficulty
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
.clamp(1, 10),
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
}
}
fn build_config_from_message(
session: &Match3DAgentSessionRecord,
payload: &SendMatch3DAgentMessageRequest,
) -> Match3DConfigJson {
let current = resolve_config_or_default(session.config.as_ref());
let text = payload.text.trim();
let reference_image_src = payload
.reference_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or(current.reference_image_src);
let quick_fill_requested =
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
let mut theme_text = current.theme_text;
let mut clear_count = current.clear_count.max(1);
let mut difficulty = current.difficulty.clamp(1, 10);
let asset_style_id = current.asset_style_id;
let asset_style_label = current.asset_style_label;
let asset_style_prompt = current.asset_style_prompt;
let generate_click_sound = current.generate_click_sound;
match session.current_turn {
0 => {
theme_text = if quick_fill_requested {
MATCH3D_DEFAULT_THEME.to_string()
} else {
parse_theme_answer(text).unwrap_or(theme_text)
};
}
1 => {
clear_count = if quick_fill_requested {
clear_count
} else {
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
.unwrap_or(clear_count)
}
.max(1);
}
_ => {
difficulty = if quick_fill_requested {
difficulty
} else {
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
}
.clamp(1, 10);
}
}
Match3DConfigJson {
theme_text,
reference_image_src,
clear_count,
difficulty,
asset_style_id,
asset_style_label,
asset_style_prompt,
generate_click_sound,
}
}
fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
config
.map(|config| Match3DConfigJson {
theme_text: config.theme_text.clone(),
reference_image_src: config.reference_image_src.clone(),
clear_count: config.clear_count.max(1),
difficulty: config.difficulty.clamp(1, 10),
asset_style_id: config.asset_style_id.clone(),
asset_style_label: config.asset_style_label.clone(),
asset_style_prompt: config.asset_style_prompt.clone(),
generate_click_sound: config.generate_click_sound,
})
.unwrap_or_else(|| Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
})
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
serde_json::to_string(config).ok()
}
fn build_seed_text(
payload: &CreateMatch3DAgentSessionRequest,
config: &Match3DConfigJson,
) -> String {
payload
.seed_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"{}题材,消除{}次,难度{}",
config.theme_text, config.clear_count, config.difficulty
)
})
}
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
format!(
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}",
config.theme_text,
config.clear_count,
config.clear_count.saturating_mul(3),
config.difficulty
)
}
fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
match current_turn {
0 => MATCH3D_QUESTION_THEME.to_string(),
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
_ => build_match3d_assistant_reply(config),
}
}
fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
match current_turn {
0 => 0,
1 => 33,
2 => 66,
_ => 100,
}
}
fn parse_theme_answer(text: &str) -> Option<String> {
for marker in ["题材", "主题"] {
if let Some((_, value)) = text.split_once(marker) {
let normalized = value
.trim_matches(|ch: char| ch == ':' || ch == '' || ch.is_whitespace())
.split_whitespace()
.next()
.unwrap_or_default()
.trim_matches(['。', '', ',', ';', ''])
.to_string();
if !normalized.is_empty() {
return Some(normalized);
}
}
}
let trimmed = text.trim();
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
{
return Some(trimmed.to_string());
}
None
}
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
for keyword in keywords {
if let Some(index) = text.find(keyword) {
let suffix = &text[index + keyword.len()..];
if let Some(value) = first_positive_integer(suffix) {
return Some(value);
}
}
}
first_positive_integer(text)
}
fn first_positive_integer(text: &str) -> Option<u32> {
let mut digits = String::new();
for ch in text.chars() {
if ch.is_ascii_digit() {
digits.push(ch);
} else if !digits.is_empty() {
break;
}
}
digits.parse::<u32>().ok().filter(|value| *value > 0)
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for tag in tags {
let trimmed = normalize_match3d_tag(tag.as_str());
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
result.push(trimmed);
}
if result.len() >= 6 {
break;
}
}
result
}
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_match3d_tag(value: &str) -> String {
let trimmed = value.trim();
let without_number_prefix = trimmed
.char_indices()
.find_map(|(index, ch)| {
if index == 0 || !matches!(ch, '.' | '、' | ')' | '') {
return None;
}
let prefix = &trimmed[..index];
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
Some(trimmed[index + ch.len_utf8()..].trim_start())
} else {
None
}
})
.unwrap_or(trimmed);
without_number_prefix
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.collect::<String>()
.chars()
.take(6)
.collect::<String>()
}
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_match3d_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= 6 {
break;
}
}
for fallback in ["抓大鹅", "经典消除", "2D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Vec<String> {
request_match3d_work_tags_with_llm(state, game_name, theme_text, summary)
.await
.unwrap_or_else(|| fallback_match3d_work_tags(game_name, theme_text))
}
async fn request_match3d_work_tags_with_llm(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: Option<&str>,
) -> Option<Vec<String>> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return None;
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n作品描述:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text,
game_name,
summary.unwrap_or_default()
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = parse_match3d_tags_from_text(response.content.as_str());
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
return Some(tags);
}
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
"抓大鹅 AI 标签生成数量不足,降级使用本地标签"
);
None
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
None
}
}
}
async fn generate_match3d_work_tags_for_plan(
state: &AppState,
game_name: &str,
theme_text: &str,
summary: &str,
plan_tags: &[String],
) -> Vec<String> {
if let Some(tags) =
request_match3d_work_tags_with_llm(state, game_name, theme_text, Some(summary)).await
{
return tags;
}
merge_match3d_plan_tags_with_fallback(game_name, theme_text, plan_tags)
}
fn merge_match3d_plan_tags_with_fallback(
game_name: &str,
theme_text: &str,
plan_tags: &[String],
) -> Vec<String> {
let mut candidates = plan_tags.to_vec();
candidates.extend(fallback_match3d_work_tags(game_name, theme_text));
normalize_match3d_tag_candidates(candidates)
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('[')
&& let Some(end) = raw.rfind(']')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
}
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
return None;
}
let items = assets
.iter()
.cloned()
.map(Match3DGeneratedItemAssetJson::from)
.collect::<Vec<_>>();
serde_json::to_string(&items).ok()
}
fn parse_match3d_generated_item_assets(value: Option<&str>) -> Vec<Match3DGeneratedItemAssetJson> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<Vec<Match3DGeneratedItemAssetJson>>(value).ok())
.unwrap_or_default()
}
impl From<Match3DGeneratedItemAsset> for Match3DGeneratedItemAssetJson {
fn from(asset: Match3DGeneratedItemAsset) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset.image_views,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset.background_asset,
status: asset.status,
error: asset.error,
}
}
}
impl From<Match3DGeneratedItemAssetJson> for Match3DGeneratedItemAsset {
fn from(asset: Match3DGeneratedItemAssetJson) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset.image_views,
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset.background_asset,
status: asset.status,
error: asset.error,
}
}
}
impl From<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
for Match3DGeneratedItemAsset
{
fn from(asset: shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse) -> Self {
Self {
item_id: asset.item_id,
item_name: asset.item_name,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_from_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(|asset| Match3DGeneratedBackgroundAsset {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}),
status: asset.status,
error: asset.error,
}
}
}
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
async fn generate_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
background_music_plan: Match3DGeneratedBackgroundMusicPlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
// 中文注释:外部生图、音频和 OSS 写入都留在 api-serverSpacetimeDB reducer 只保存确定性草稿。
let target_item_count = resolve_match3d_generated_item_count(config);
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
if has_match3d_required_item_images(&assets, target_item_count)
&& (!config.generate_click_sound
|| assets
.iter()
.take(target_item_count)
.all(|asset| asset.click_sound.is_some()))
&& assets
.iter()
.take(target_item_count)
.any(has_match3d_background_music_audio)
{
return Ok(assets.into_iter().take(target_item_count).collect());
}
if !has_match3d_required_item_images(&assets, target_item_count) {
assets = ensure_match3d_item_image_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
item_plan,
background_music_plan.clone(),
assets,
)
.await?;
}
let assets = ensure_match3d_background_music_asset(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
&background_music_plan,
assets,
)
.await?;
let assets = ensure_match3d_click_sound_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
assets,
)
.await?;
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
Ok(assets.into_iter().take(target_item_count).collect())
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_item_image_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
background_music_plan: Match3DGeneratedBackgroundMusicPlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
let target_item_count = resolve_match3d_generated_item_count(config);
let item_plan = normalize_match3d_item_plan(config, item_plan);
let missing_items = item_plan
.iter()
.take(target_item_count)
.enumerate()
.filter_map(|(index, item)| {
let item_id = format!("match3d-item-{}", index + 1);
if assets.iter().any(|asset| {
asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset)
}) {
return None;
}
Some(Match3DItemImageGenerationSeed {
item_id,
item_name: item.name.clone(),
sound_prompt: item.sound_prompt.clone(),
persist_asset: true,
background_music_title: if index == 0 {
Some(background_music_plan.title.clone())
} else {
None
},
background_music_style: if index == 0 {
Some(background_music_plan.style.clone())
} else {
None
},
background_music_prompt: if index == 0 {
Some(background_music_plan.prompt.clone())
} else {
None
},
background_asset: if index == 0 {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
} else {
None
},
})
})
.collect::<Vec<_>>();
let generated_assets = generate_match3d_item_image_assets_in_batches(
state,
request_context,
MATCH3D_AGENT_PROVIDER,
owner_user_id,
session_id,
profile_id,
config,
missing_items,
)
.await?;
for generated_asset in generated_assets
.into_iter()
.filter(|generated| generated.persist_asset)
.map(|generated| generated.asset)
{
upsert_match3d_generated_item_asset(&mut assets, generated_asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
Ok(assets)
}
#[derive(Clone)]
struct Match3DItemImageGenerationSeed {
item_id: String,
item_name: String,
sound_prompt: String,
persist_asset: bool,
background_music_title: Option<String>,
background_music_style: Option<String>,
background_music_prompt: Option<String>,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
}
struct Match3DMaterialBatchOutput {
task_id: String,
generated_at_micros: i64,
items: Vec<(Match3DItemImageGenerationSeed, Vec<Match3DSlicedItemImage>)>,
}
struct Match3DGeneratedItemImageAssetOutput {
asset: Match3DGeneratedItemAsset,
persist_asset: bool,
}
#[allow(clippy::too_many_arguments)]
async fn generate_match3d_item_image_assets_in_batches(
state: &AppState,
request_context: &RequestContext,
provider: &str,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
item_seeds: Vec<Match3DItemImageGenerationSeed>,
) -> Result<Vec<Match3DGeneratedItemImageAssetOutput>, Response> {
if item_seeds.is_empty() {
return Ok(Vec::new());
}
require_match3d_oss_client(state)
.map_err(|error| match3d_error_response(request_context, provider, error))?;
let mut batch_tasks = item_seeds
.chunks(MATCH3D_MATERIAL_ITEM_BATCH_SIZE)
.map(|chunk| {
let chunk_seeds = chunk.to_vec();
async move {
let item_names = chunk_seeds
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let material_sheet =
generate_match3d_material_sheet(state, config, &item_names).await?;
let generated_at_micros = current_utc_micros();
let persisted_seed_count = chunk_seeds
.iter()
.position(|seed| !seed.persist_asset)
.unwrap_or(chunk_seeds.len());
debug_assert!(
chunk_seeds[persisted_seed_count..]
.iter()
.all(|seed| !seed.persist_asset)
);
let persisted_seeds = chunk_seeds
.into_iter()
.take(persisted_seed_count)
.collect::<Vec<_>>();
let persisted_item_names = persisted_seeds
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let item_images =
slice_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?;
Ok::<_, AppError>(Match3DMaterialBatchOutput {
task_id: material_sheet.task_id,
generated_at_micros,
items: persisted_seeds
.into_iter()
.zip(item_images.into_iter())
.collect::<Vec<_>>(),
})
}
})
.collect::<FuturesUnordered<_>>();
let mut batches = Vec::new();
while let Some(batch_result) = batch_tasks.next().await {
batches.push(
batch_result
.map_err(|error| match3d_error_response(request_context, provider, error))?,
);
}
let mut generated_assets = Vec::new();
for batch in batches {
let sheet_task_id = batch.task_id;
let generated_at_micros = batch.generated_at_micros;
for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() {
let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str());
let mut image_views = Vec::with_capacity(item_images.len());
for (view_index, item_image) in item_images.into_iter().enumerate() {
let view_number = view_index + 1;
let view_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["items", item_slug.as_str(), "views"],
format!("view-{view_number:02}.png").as_str(),
"image/png",
item_image.bytes,
"match3d_item_image_view",
Some(sheet_task_id.as_str()),
generated_at_micros.saturating_add(
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
),
)
.await
.map_err(|error| match3d_error_response(request_context, provider, error))?;
image_views.push(Match3DGeneratedItemImageView {
view_id: format!("view-{view_number:02}"),
view_index: view_number as u32,
image_src: Some(view_upload.src),
image_object_key: Some(view_upload.object_key),
});
}
let primary_view = image_views.first().cloned();
generated_assets.push(Match3DGeneratedItemImageAssetOutput {
persist_asset: seed.persist_asset,
asset: Match3DGeneratedItemAsset {
item_id: seed.item_id,
item_name: seed.item_name,
image_src: primary_view
.as_ref()
.and_then(|view| view.image_src.clone()),
image_object_key: primary_view
.as_ref()
.and_then(|view| view.image_object_key.clone()),
image_views,
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: Some(seed.sound_prompt),
background_music_title: seed.background_music_title,
background_music_style: seed.background_music_style,
background_music_prompt: seed.background_music_prompt,
background_music: None,
click_sound: None,
background_asset: seed.background_asset,
status: "image_ready".to_string(),
error: None,
},
});
}
}
generated_assets.sort_by(|left, right| {
match3d_item_sort_index(left.asset.item_id.as_str())
.cmp(&match3d_item_sort_index(right.asset.item_id.as_str()))
.then_with(|| left.asset.item_id.cmp(&right.asset.item_id))
});
Ok(generated_assets)
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_click_sound_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
if !config.generate_click_sound {
return Ok(assets);
}
let mut assets = normalize_match3d_generated_item_assets_for_resume(assets);
let seeds = assets
.iter()
.filter(|asset| is_match3d_generated_asset_image_ready(asset))
.filter(|asset| asset.click_sound.is_none())
.cloned()
.collect::<Vec<_>>();
if seeds.is_empty() {
return Ok(assets);
}
let mut sound_tasks = seeds
.into_iter()
.map(|asset| async move {
let prompt = asset
.sound_prompt
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
build_fallback_match3d_item_sound_prompt(config, asset.item_name.as_str())
});
let result = generate_match3d_click_sound_asset(
state,
owner_user_id,
profile_id,
asset.item_id.as_str(),
asset.item_name.as_str(),
prompt.as_str(),
)
.await;
(asset, prompt, result)
})
.collect::<FuturesUnordered<_>>();
while let Some((mut asset, prompt, result)) = sound_tasks.next().await {
match result {
Ok(click_sound) => {
asset.sound_prompt = Some(prompt);
asset.click_sound = Some(click_sound);
asset.error = None;
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
session_id,
profile_id,
item_id = asset.item_id.as_str(),
error = %error,
"抓大鹅入口内联点击音效生成失败,保留草稿并允许结果页重试"
);
}
}
upsert_match3d_generated_item_asset(&mut assets, asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
Ok(assets)
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_background_music_asset(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
plan: &Match3DGeneratedBackgroundMusicPlan,
assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = normalize_match3d_generated_item_assets_for_resume(assets);
if assets.iter().any(|asset| {
asset
.background_music
.as_ref()
.is_some_and(|music| !music.audio_src.trim().is_empty())
}) {
return Ok(assets);
}
let Some(first_index) = assets
.iter()
.enumerate()
.min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str()))
.map(|(index, _)| index)
else {
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
match3d_background_music_missing_error("抓大鹅草稿缺少可写入背景音乐的物品素材"),
));
};
let title = require_match3d_background_music_title(plan).map_err(|error| {
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
})?;
let style = normalize_match3d_audio_style(plan.style.as_str());
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
.await
{
Ok(music) => {
let asset = &mut assets[first_index];
asset.background_music_title = Some(title);
asset.background_music_style = (!style.trim().is_empty()).then_some(style);
asset.background_music_prompt = Some(String::new());
asset.background_music = Some(music);
asset.error = None;
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
Err(error) => {
tracing::error!(
provider = MATCH3D_AGENT_PROVIDER,
session_id,
profile_id,
error = %error,
"抓大鹅草稿背景音乐生成失败,终止本次草稿生成并等待重试"
);
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
error,
));
}
}
Ok(assets)
}
async fn generate_match3d_click_sound_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
item_id: &str,
item_name: &str,
prompt: &str,
) -> Result<CreationAudioAsset, AppError> {
let mut asset = generate_sound_effect_asset_for_creation(
state,
owner_user_id,
prompt.to_string(),
Some(3),
None,
GeneratedCreationAudioTarget {
entity_kind: "match3d_item".to_string(),
entity_id: item_id.to_string(),
slot: "click_sound".to_string(),
asset_kind: MATCH3D_CLICK_SOUND_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::Match3DAssets,
},
)
.await?;
asset.title = Some(format!("{item_name}点击音效"));
Ok(asset)
}
async fn generate_match3d_background_music_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
title: &str,
style: &str,
) -> Result<CreationAudioAsset, AppError> {
generate_background_music_asset_for_creation(
state,
owner_user_id,
String::new(),
title.to_string(),
(!style.trim().is_empty()).then_some(style.to_string()),
None,
GeneratedCreationAudioTarget {
entity_kind: "match3d_work".to_string(),
entity_id: profile_id.to_string(),
slot: "background_music".to_string(),
asset_kind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::Match3DAssets,
},
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn append_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
append_plan: Match3DItemAssetAppendPlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = sort_match3d_generated_assets(existing_assets);
let existing_item_count = assets.len();
let requested_item_count = append_plan.requested_item_names.len();
if requested_item_count == 0 {
return Ok(assets);
}
let mut next_item_index = next_match3d_generated_item_index(&assets);
let item_seeds = append_plan
.padded_item_names
.into_iter()
.enumerate()
.map(|(index, item_name)| {
let item_id = allocate_match3d_generated_item_id(&assets, &mut next_item_index);
Match3DItemImageGenerationSeed {
item_id,
sound_prompt: build_fallback_match3d_item_sound_prompt(config, item_name.as_str()),
item_name,
persist_asset: index < requested_item_count,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_asset: None,
}
})
.collect::<Vec<_>>();
let generated_assets = generate_match3d_item_image_assets_in_batches(
state,
request_context,
MATCH3D_WORKS_PROVIDER,
owner_user_id,
session_id,
profile_id,
config,
item_seeds,
)
.await?;
for generated_asset in generated_assets
.into_iter()
.filter(|generated| generated.persist_asset)
.map(|generated| generated.asset)
{
upsert_match3d_generated_item_asset(&mut assets, generated_asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
ensure_match3d_click_sound_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
assets,
)
.await
.map(|assets| {
sort_match3d_generated_assets(assets)
.into_iter()
.take(existing_item_count + requested_item_count)
.collect()
})
}
struct Match3DMaterialSheet {
task_id: String,
image: DownloadedOpenAiImage,
}
struct Match3DApimartImageSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
async fn generate_match3d_draft_plan(
state: &AppState,
config: &Match3DConfigJson,
) -> Match3DGeneratedDraftPlan {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_draft_plan(config);
};
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
let generated_item_count = resolve_match3d_generated_item_count(config);
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundMusic、backgroundPrompt、items。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”summary 为 18 到 48 个中文字符的作品描述说明题材氛围和核心体验不要写规则说明tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字后续会用同一作品信息再次生成作品标签backgroundMusic 包含 title、style、prompttitle 是本作品背景音乐名称且不超过 40 个中文字符style 是逗号分隔的音乐风格prompt 必须返回空字符串backgroundPrompt 是用于生成局内纯背景图的中文提示词只描述竖屏移动端抓大鹅题材氛围、色彩和环境不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name 和 soundPromptname 为 2 到 6 个汉字soundPrompt 是用于生成物品点击消除音效的中文提示词。",
config.theme_text, gameplay_item_count, generated_item_count
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
theme_text = config.theme_text.as_str(),
error = %error,
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
);
fallback_match3d_draft_plan(config)
}
}
}
fn parse_match3d_draft_plan(
raw: &str,
config: &Match3DConfigJson,
) -> Option<Match3DGeneratedDraftPlan> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('{')
&& let Some(end) = raw.rfind('}')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let value = serde_json::from_str::<Value>(json_text).ok()?;
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
if game_name.is_empty() {
return None;
}
let tags = value
.get("tags")
.and_then(Value::as_array)
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
.unwrap_or_default();
let fallback = fallback_match3d_draft_plan(config);
let summary = value
.get("summary")
.or_else(|| value.get("description"))
.or_else(|| value.get("workSummary"))
.or_else(|| value.get("work_summary"))
.and_then(Value::as_str)
.map(normalize_match3d_work_summary)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.metadata.summary);
let items = value
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let name =
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
if name.is_empty() {
return None;
}
let sound_prompt = item
.get("soundPrompt")
.or_else(|| item.get("sound_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_audio_prompt)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
Some(Match3DGeneratedItemPlan { name, sound_prompt })
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let background_music = value
.get("backgroundMusic")
.or_else(|| value.get("background_music"))
.and_then(|music| {
let title = music
.get("title")
.and_then(Value::as_str)
.map(normalize_match3d_audio_title)
.filter(|value| !value.is_empty())?;
let style = music
.get("style")
.and_then(Value::as_str)
.map(normalize_match3d_audio_style)
.filter(|value| !value.is_empty())?;
let prompt = music
.get("prompt")
.and_then(Value::as_str)
.map(normalize_match3d_audio_prompt)
.unwrap_or_default();
Some(Match3DGeneratedBackgroundMusicPlan {
title,
style,
prompt,
})
})
.unwrap_or(fallback.background_music);
let background_prompt = value
.get("backgroundPrompt")
.or_else(|| value.get("background_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_background_prompt)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.background_prompt);
Some(Match3DGeneratedDraftPlan {
metadata: Match3DGeneratedWorkMetadata {
game_name,
summary,
tags: normalize_match3d_tag_candidates(tags),
},
items: normalize_match3d_item_plan(config, items),
background_music,
background_prompt,
})
}
#[cfg(test)]
fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
let config = Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
};
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
}
fn normalize_match3d_game_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(16)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_work_summary(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”'])
.split_whitespace()
.collect::<Vec<_>>()
.join("")
.chars()
.filter(|character| !character.is_control())
.take(80)
.collect::<String>()
.trim()
.to_string()
}
fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
Match3DGeneratedWorkMetadata {
game_name: format!("{normalized_theme}抓大鹅"),
summary: normalize_match3d_work_summary(
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
),
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
}
}
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let items = fallback_match3d_item_names(config.theme_text.as_str())
.into_iter()
.take(resolve_match3d_generated_item_count(config))
.map(|name| Match3DGeneratedItemPlan {
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
})
.collect::<Vec<_>>();
Match3DGeneratedDraftPlan {
background_music: build_fallback_match3d_background_music_plan(config, &metadata.game_name),
background_prompt: build_fallback_match3d_background_prompt(config),
metadata,
items,
}
}
fn normalize_match3d_item_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(12)
.collect::<String>()
.trim()
.to_string()
}
fn fallback_match3d_item_names(theme_text: &str) -> Vec<String> {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
[
"小物件",
"徽章",
"摆件",
"挂件",
"圆球",
"方块",
"钥匙",
"杯子",
"糖果",
"星星",
"宝石",
"铃铛",
"叶片",
"蘑菇",
"花朵",
"果冻",
"小瓶",
"帽子",
"贝壳",
"纽扣",
"积木",
"印章",
"彩蛋",
"小鼓",
"风车",
]
.into_iter()
.map(|suffix| format!("{normalized_theme}{suffix}"))
.take(MATCH3D_MAX_GENERATED_ITEM_COUNT)
.collect()
}
fn normalize_match3d_item_plan(
config: &Match3DConfigJson,
items: Vec<Match3DGeneratedItemPlan>,
) -> Vec<Match3DGeneratedItemPlan> {
let target_item_count = resolve_match3d_generated_item_count(config);
let mut normalized = Vec::new();
for item in items {
let name = normalize_match3d_item_name(item.name.as_str());
if name.is_empty()
|| normalized
.iter()
.any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name)
{
continue;
}
let sound_prompt = normalize_match3d_audio_prompt(item.sound_prompt.as_str());
normalized.push(Match3DGeneratedItemPlan {
sound_prompt: if sound_prompt.is_empty() {
build_fallback_match3d_item_sound_prompt(config, &name)
} else {
sound_prompt
},
name,
});
if normalized.len() >= target_item_count {
break;
}
}
if normalized.len() < target_item_count {
for name in fallback_match3d_item_names(config.theme_text.as_str()) {
if normalized.iter().any(|candidate| candidate.name == name) {
continue;
}
normalized.push(Match3DGeneratedItemPlan {
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
});
if normalized.len() >= target_item_count {
break;
}
}
}
if normalized.len() < target_item_count {
fill_match3d_item_plan_to_count(config, &mut normalized, target_item_count);
}
normalized
}
fn fill_match3d_item_plan_to_count(
config: &Match3DConfigJson,
normalized: &mut Vec<Match3DGeneratedItemPlan>,
target_item_count: usize,
) {
let normalized_theme = config.theme_text.trim();
let fallback_prefix = if normalized_theme.is_empty() {
"补充物品".to_string()
} else {
format!("{normalized_theme}补充")
};
let mut index = 1usize;
while normalized.len() < target_item_count {
let name = normalize_match3d_item_name(format!("{fallback_prefix}{index}").as_str());
if !name.is_empty()
&& !normalized
.iter()
.any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name)
{
normalized.push(Match3DGeneratedItemPlan {
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
});
}
index += 1;
}
}
fn normalize_match3d_batch_item_names(items: Vec<String>) -> Vec<String> {
let mut normalized: Vec<String> = Vec::new();
for item in items {
let name = normalize_match3d_item_name(item.as_str());
if name.is_empty() || normalized.iter().any(|candidate| candidate == &name) {
continue;
}
normalized.push(name);
if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT {
break;
}
}
normalized
}
fn build_match3d_item_asset_append_plan(
item_names: Vec<String>,
existing_assets: &[Match3DGeneratedItemAsset],
) -> Match3DItemAssetAppendPlan {
let available_capacity = MATCH3D_MAX_GENERATED_ITEM_COUNT.saturating_sub(existing_assets.len());
let mut requested_item_names = item_names
.into_iter()
.filter(|name| {
!existing_assets
.iter()
.any(|asset| asset.item_name.trim() == name.trim())
})
.take(available_capacity)
.collect::<Vec<_>>();
requested_item_names.truncate(available_capacity);
let padded_item_names = build_match3d_padded_item_names_for_generation(
&requested_item_names,
existing_assets,
available_capacity,
);
Match3DItemAssetAppendPlan {
requested_item_names,
padded_item_names,
}
}
fn build_match3d_padded_item_names_for_generation(
item_names: &[String],
existing_assets: &[Match3DGeneratedItemAsset],
available_capacity: usize,
) -> Vec<String> {
let mut padded = item_names
.iter()
.take(available_capacity)
.cloned()
.collect::<Vec<_>>();
let target_item_count = round_match3d_item_count_to_full_sheet(padded.len());
let mut fallback_index = 1usize;
while padded.len() < target_item_count {
let candidate = normalize_match3d_item_name(format!("追加物品{fallback_index}").as_str());
fallback_index += 1;
if candidate.is_empty()
|| padded.iter().any(|name| name == &candidate)
|| existing_assets
.iter()
.any(|asset| asset.item_name.trim() == candidate.as_str())
{
continue;
}
padded.push(candidate);
}
padded
}
fn calculate_match3d_item_assets_points_cost(item_count: usize) -> u64 {
if item_count == 0 {
return 0;
}
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) as u64
* MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
}
fn normalize_match3d_cover_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(900)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_audio_title(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(40)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_audio_style(raw: &str) -> String {
raw.split([',', '', '、', '\n'])
.map(normalize_match3d_tag)
.filter(|value| !value.is_empty())
.take(6)
.collect::<Vec<_>>()
.join(", ")
}
fn normalize_match3d_audio_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(500)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_background_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(900)
.collect::<String>()
.trim()
.to_string()
}
fn build_match3d_prompt_fingerprint(value: &str) -> String {
let mut hash = 0u32;
for character in value.chars() {
hash = hash.wrapping_mul(31).wrapping_add(character as u32);
}
format!("{hash:08x}")
}
fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String {
let theme = config.theme_text.trim();
let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme };
normalize_match3d_background_prompt(
format!(
"{normalized_theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,方便运行态叠加默认交互容器。无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品、无角色、无手。"
)
.as_str(),
)
}
fn build_fallback_match3d_background_music_plan(
_config: &Match3DConfigJson,
game_name: &str,
) -> Match3DGeneratedBackgroundMusicPlan {
let title = normalize_match3d_audio_title(format!("{game_name}音乐").as_str());
let style = "轻快, 休闲, 消除, instrumental".to_string();
Match3DGeneratedBackgroundMusicPlan {
title,
style: style.clone(),
prompt: String::new(),
}
}
fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String {
let theme = config.theme_text.trim();
let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme };
normalize_match3d_audio_prompt(
format!(
"{normalized_theme}题材抓大鹅中“{item_name}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。"
)
.as_str(),
)
}
fn normalize_match3d_generated_item_assets_for_resume(
assets: Vec<Match3DGeneratedItemAsset>,
) -> Vec<Match3DGeneratedItemAsset> {
let mut normalized = Vec::new();
for asset in sort_match3d_generated_assets(assets) {
if asset.item_id.trim().is_empty()
|| normalized
.iter()
.any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id)
{
continue;
}
normalized.push(asset);
if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT {
break;
}
}
normalized
}
fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> usize {
match config.clear_count {
8 => 3,
12 => 9,
16 => 15,
20 | 21 => 21,
_ => match config.difficulty {
0..=2 => 3,
3..=4 => 9,
5..=6 => 15,
_ => 21,
},
}
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
}
fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize {
round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config))
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
}
fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
if item_count == 0 {
return 0;
}
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE
}
fn sort_match3d_generated_assets(
mut assets: Vec<Match3DGeneratedItemAsset>,
) -> Vec<Match3DGeneratedItemAsset> {
assets.sort_by(|left, right| {
match3d_item_sort_index(left.item_id.as_str())
.cmp(&match3d_item_sort_index(right.item_id.as_str()))
.then_with(|| left.item_id.cmp(&right.item_id))
});
assets
}
fn match3d_item_sort_index(item_id: &str) -> u32 {
item_id
.rsplit('-')
.next()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(u32::MAX)
}
fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool {
let view_count = asset
.image_views
.iter()
.filter(|view| {
view.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| view
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
})
.count();
view_count >= MATCH3D_ITEM_VIEW_COUNT
}
fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool {
asset
.background_music
.as_ref()
.map(|music| music.audio_src.trim())
.is_some_and(|value| !value.is_empty())
}
fn has_match3d_required_item_images(
assets: &[Match3DGeneratedItemAsset],
required_item_count: usize,
) -> bool {
assets.len() >= required_item_count
&& assets
.iter()
.take(required_item_count)
.all(is_match3d_generated_asset_image_ready)
}
fn upsert_match3d_generated_item_asset(
assets: &mut Vec<Match3DGeneratedItemAsset>,
asset: Match3DGeneratedItemAsset,
) {
if let Some(current) = assets
.iter_mut()
.find(|candidate| candidate.item_id == asset.item_id)
{
*current = asset;
*assets = sort_match3d_generated_assets(std::mem::take(assets));
return;
}
assets.push(asset);
*assets = sort_match3d_generated_assets(std::mem::take(assets));
}
fn next_match3d_generated_item_index(assets: &[Match3DGeneratedItemAsset]) -> u32 {
assets
.iter()
.filter_map(|asset| {
let value = match3d_item_sort_index(asset.item_id.as_str());
if value == u32::MAX { None } else { Some(value) }
})
.max()
.unwrap_or(0)
.saturating_add(1)
}
fn allocate_match3d_generated_item_id(
assets: &[Match3DGeneratedItemAsset],
next_item_index: &mut u32,
) -> String {
loop {
let candidate = format!("match3d-item-{}", *next_item_index);
*next_item_index = next_item_index.saturating_add(1);
if !assets.iter().any(|asset| asset.item_id == candidate) {
return candidate;
}
}
}
fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgroundAsset) -> bool {
asset.status == "image_ready"
&& (asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some())
&& (asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.container_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some())
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_background_asset(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_prompt: &str,
mut assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
let resolved_prompt = if normalized_prompt.is_empty() {
build_fallback_match3d_background_prompt(config)
} else {
normalized_prompt
};
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
if is_match3d_background_asset_ready(&existing_background) {
return Ok(assets);
}
}
let generated_background = generate_match3d_background_image(
state,
owner_user_id,
session_id,
profile_id,
config,
&resolved_prompt,
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
attach_match3d_background_asset_to_assets(&mut assets, generated_background);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
Ok(assets)
}
fn attach_match3d_background_asset_to_assets(
assets: &mut Vec<Match3DGeneratedItemAsset>,
background_asset: Match3DGeneratedBackgroundAsset,
) {
if let Some(first_asset) = assets
.iter_mut()
.min_by_key(|asset| match3d_item_sort_index(asset.item_id.as_str()))
{
first_asset.background_asset = Some(background_asset);
}
}
fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String {
format!(
"{}-{}",
sanitize_match3d_asset_segment(item_id, "match3d-item"),
sanitize_match3d_asset_segment(item_name, "item")
)
}
async fn generate_match3d_cover_image_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
reference_image_src: Option<String>,
) -> Result<Match3DAssetUpload, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = resolve_match3d_reference_image_data_url(
state,
reference_image_src.as_deref(),
MATCH3D_ITEM_IMAGE_MAX_BYTES,
)
.await?;
let reference_images = reference_image.into_iter().collect::<Vec<_>>();
let generated = create_openai_image_generation(
&http_client,
&settings,
build_match3d_cover_generation_prompt(config, prompt).as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
reference_images.as_slice(),
"抓大鹅封面图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅封面图生成失败:未返回图片",
}))
})?;
let file_name = format!("cover.{}", image.extension);
persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["cover", generated.task_id.as_str()],
file_name.as_str(),
image.mime_type.as_str(),
image.bytes,
"match3d_cover_image",
Some(generated.task_id.as_str()),
current_utc_micros(),
)
.await
}
fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格遵循:{style}"))
.unwrap_or_default();
format!(
"{theme}题材抓大鹅作品封面图。{style_clause}{prompt}。画面为1:1封面主体清晰、色彩明亮、适合移动端作品卡片展示可以包含生成物品或主题元素但不要出现任何文字、按钮、倒计时、分数或 UI。",
theme = config.theme_text,
style_clause = style_clause,
prompt = prompt,
)
}
async fn generate_match3d_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_data_url().await?;
let generated_background = create_openai_image_generation(
&http_client,
&settings,
build_match3d_background_generation_prompt(config, prompt).as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单"),
"9:16",
1,
&[],
"抓大鹅背景图生成失败",
)
.await?;
let background_image = generated_background
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅背景图生成失败:未返回图片",
}))
})?;
let background_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["background", generated_background.task_id.as_str()],
"background.png",
background_image.mime_type.as_str(),
background_image.bytes,
"match3d_background_image",
Some(generated_background.task_id.as_str()),
current_utc_micros(),
)
.await?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
let generated_container = create_openai_image_generation(
&http_client,
&settings,
container_prompt.as_str(),
Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"),
"1:1",
1,
std::slice::from_ref(&reference_image),
"抓大鹅容器 UI 图生成失败",
)
.await?;
let container_image = generated_container
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
}))
})?;
let container_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["ui-container", generated_container.task_id.as_str()],
"container.png",
container_image.mime_type.as_str(),
container_image.bytes,
"match3d_ui_container_image",
Some(generated_container.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DGeneratedBackgroundAsset {
prompt: prompt.to_string(),
image_src: Some(background_upload.src),
image_object_key: Some(background_upload.object_key),
container_prompt: Some(container_prompt),
container_image_src: Some(container_upload.src),
container_image_object_key: Some(container_upload.object_key),
status: "image_ready".to_string(),
error: None,
})
}
async fn load_match3d_container_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": format!("读取抓大鹅容器参考图失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅容器参考图为空",
})),
);
}
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(bytes)
))
}
fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default();
format!(
"{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图只表现题材氛围、色彩层次和场景环境。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透方便运行态后续叠加默认交互容器和物品素材。"
)
}
fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default();
format!(
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明或纯净留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须透明感或纯净留白,不能做成整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
)
}
async fn generate_match3d_material_sheet(
state: &AppState,
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_match3d_apimart_image_settings(state)?;
let http_client = build_match3d_apimart_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
let generated = create_match3d_apimart_nanobanana_image_generation(
&http_client,
&settings,
prompt.as_str(),
negative_prompt.as_str(),
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
image,
})
}
fn require_match3d_apimart_image_settings(
state: &AppState,
) -> Result<Match3DApimartImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
}))
})?;
Ok(Match3DApimartImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
})
}
fn build_match3d_apimart_image_http_client(
settings: &Match3DApimartImageSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造抓大鹅 APIMart 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
async fn create_match3d_apimart_nanobanana_image_generation(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
prompt: &str,
negative_prompt: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_match3d_apimart_nanobanana_image_request_body(
prompt,
negative_prompt,
MATCH3D_MATERIAL_APIMART_SIZE,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:创建 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
status,
response_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
response_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成响应失败",
"apimart",
)?;
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
format!("apimart-nanobanana-{}", current_utc_micros()),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(
format!("apimart-nanobanana-{}", current_utc_micros()),
b64_images,
1,
));
}
let task_id = extract_match3d_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成未返回 task_id 或图片地址",
}))
})?;
wait_match3d_apimart_generated_images(http_client, settings, task_id.as_str(), failure_context)
.await
}
fn build_match3d_apimart_nanobanana_image_request_body(
prompt: &str,
negative_prompt: &str,
size: &str,
) -> Value {
Value::Object(serde_json::Map::from_iter([
(
"model".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_match3d_apimart_prompt(prompt, negative_prompt)),
),
("n".to_string(), json!(1)),
("official_fallback".to_string(), Value::Bool(true)),
("size".to_string(), Value::String(size.to_string())),
(
"resolution".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_RESOLUTION.to_string()),
),
]))
}
fn build_match3d_apimart_prompt(prompt: &str, negative_prompt: &str) -> String {
let prompt = prompt.trim();
let negative_prompt = negative_prompt.trim();
if negative_prompt.is_empty() {
return prompt.to_string();
}
format!("{prompt}\n避免:{negative_prompt}")
}
async fn wait_match3d_apimart_generated_images(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
task_id: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = std::time::Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while std::time::Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:查询 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
poll_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成任务响应失败",
"apimart",
)?;
let task_status = find_first_match3d_string_by_key(&payload, "status")
.or_else(|| find_first_match3d_string_by_key(&payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(
task_id.to_string(),
b64_images,
1,
));
}
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成成功但未返回图片",
})),
);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled"
) {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成超时或未返回图片",
})),
)
}
async fn download_match3d_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
provider: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
async fn download_match3d_remote_image(
http_client: &reqwest::Client,
image_url: &str,
provider: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("下载抓大鹅生成图片失败:{error}"),
}))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
}))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": "下载抓大鹅生成图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: body.to_vec(),
})
}
fn match3d_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
Some(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
fn parse_match3d_json_payload(
raw_text: &str,
failure_context: &str,
provider: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("{failure_context}{error}"),
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
}))
})
}
fn extract_match3d_task_id(payload: &Value) -> Option<String> {
find_first_match3d_string_by_key(payload, "task_id")
.or_else(|| find_first_match3d_string_by_key(payload, "id"))
}
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_match3d_strings_by_key(payload, "url", &mut urls);
collect_match3d_strings_by_key(payload, "image", &mut urls);
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
values
}
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_match3d_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_match3d_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn map_match3d_apimart_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
}))
}
fn map_match3d_apimart_image_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_match3d_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
tracing::warn!(
provider = "apimart",
upstream_status = upstream_status.as_u16(),
message = %message,
raw_excerpt = %raw_excerpt,
"抓大鹅 APIMart nanobanana 图片生成上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
}))
}
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
for key in ["message", "code"] {
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
return if key == "message" {
value
} else {
format!("{fallback_message}{value}")
};
}
}
}
trimmed.to_string()
}
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
raw_text.chars().take(max_chars).collect()
}
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/png");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/png".to_string(),
}
}
fn match3d_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/jpeg" | "image/jpg" => "jpg",
_ => "png",
}
}
async fn download_match3d_legacy_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_millis(
MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS,
))
.build()
.map_err(|error| match3d_bad_gateway(format!("构造历史模型下载客户端失败:{error}")))?;
tracing::info!(
provider = MATCH3D_AGENT_PROVIDER,
file_name = file.name.as_str(),
"抓大鹅历史 GLB 下载开始"
);
let response = http_client
.get(file.url.as_str())
.send()
.await
.map_err(|error| match3d_bad_gateway(format!("下载历史模型失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("model/gltf-binary")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| match3d_bad_gateway(format!("读取历史模型内容失败:{error}")))?;
if !status.is_success() {
return Err(match3d_bad_gateway(format!(
"下载历史模型失败HTTP {}",
status.as_u16()
)));
}
if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) {
return Err(match3d_bad_gateway("历史模型下载结果不是 GLB 模型文件"));
}
if bytes.is_empty() || bytes.len() > MATCH3D_LEGACY_MODEL_MAX_BYTES {
return Err(match3d_bad_gateway("历史模型内容为空或超过大小上限"));
}
if !is_match3d_glb_binary_payload(&bytes) {
return Err(match3d_bad_gateway("历史模型下载结果不是有效 GLB 模型文件"));
}
Ok(Match3DDownloadedModel {
bytes: bytes.to_vec(),
file_name: normalize_match3d_model_file_name(file.name.as_str()),
content_type: normalize_match3d_model_content_type(content_type.as_str()),
})
}
fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool {
let normalized_file_name = file_name.to_ascii_lowercase();
let normalized_content_type = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
.to_ascii_lowercase();
normalized_file_name.ends_with(".glb")
|| matches!(
normalized_content_type.as_str(),
"model/gltf-binary" | "application/octet-stream"
)
}
fn normalize_match3d_model_file_name(raw: &str) -> String {
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
let normalized = without_query.to_ascii_lowercase();
let stem = without_query
.strip_suffix(".glb")
.or_else(|| {
normalized
.strip_suffix(".glb")
.map(|_| &without_query[..without_query.len().saturating_sub(4)])
})
.unwrap_or(without_query);
let sanitized_stem = sanitize_match3d_asset_segment(stem, "model");
format!("{sanitized_stem}.glb")
}
fn normalize_match3d_model_content_type(raw: &str) -> String {
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
if normalized == "model/gltf-binary" {
return normalized;
}
"model/gltf-binary".to_string()
}
fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool {
if bytes.len() < 12 {
return false;
}
let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let declared_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len()
}
async fn read_match3d_generated_object_bytes(
state: &AppState,
object_key: &str,
message_prefix: &str,
max_size_bytes: usize,
) -> Result<Vec<u8>, AppError> {
let object_key = object_key.trim().trim_start_matches('/');
if object_key.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "match3d-assets",
"message": format!("{message_prefix}objectKey 不能为空"),
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(300),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let response = reqwest::Client::new()
.get(signed.signed_url.as_str())
.send()
.await
.map_err(|error| match3d_bad_gateway(format!("{message_prefix}{error}")))?;
let status = response.status();
if !status.is_success() {
return Err(match3d_bad_gateway(format!(
"{message_prefix}HTTP {}",
status.as_u16()
)));
}
let bytes = response
.bytes()
.await
.map_err(|error| match3d_bad_gateway(format!("{message_prefix}{error}")))?;
if bytes.is_empty() || bytes.len() > max_size_bytes {
return Err(match3d_bad_gateway(format!(
"{message_prefix}:内容为空或超过大小上限"
)));
}
Ok(bytes.to_vec())
}
async fn resolve_match3d_reference_image_data_url(
state: &AppState,
source: Option<&str>,
max_size_bytes: usize,
) -> Result<Option<String>, AppError> {
let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if source.starts_with("data:image/") {
return Ok(Some(source.to_string()));
}
if !source.trim_start_matches('/').starts_with("generated-") {
return Ok(Some(source.to_string()));
}
let bytes =
read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes)
.await?;
Ok(Some(format!(
"data:{};base64,{}",
infer_match3d_image_mime_type(bytes.as_slice()),
BASE64_STANDARD.encode(bytes)
)))
}
fn infer_match3d_image_mime_type(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return "image/png";
}
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
return "image/jpeg";
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp";
}
"image/png"
}
fn build_match3d_material_sheet_prompt(
config: &Match3DConfigJson,
item_names: &[String],
) -> String {
let asset_style_prompt = resolve_match3d_asset_style_prompt(config);
let style_clause = asset_style_prompt
.as_ref()
.map(|prompt| format!("整体画风遵循:{prompt}"))
.unwrap_or_default();
let item_rows = item_names
.iter()
.enumerate()
.map(|(index, name)| format!("{}行:{name} 的 5 个不同视角", index + 1))
.collect::<Vec<_>>()
.join("");
format!(
"生成一张1024x1024的1:1图片。固定生成5行*5列网格素材图画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格5*5均匀排布严格按行组织{item_rows}。同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;每个格子一个独立居中的完整物体,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00背景平整无纹理、无渐变、无阴影、无道具方便后续抠成透明。物体本身不得使用与绿幕相同的纯绿色若物品天然含绿色必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照清晰轮廓适合直接切割成游戏2D图标。请让每个物体完整落在自己的格子中央四周保留留白相邻物体主体之间必须至少保留单个素材格宽度的1/4空白间距约25%单格宽度包含左右相邻格和上下相邻行物体主体不得占满格子。禁止主体跨格、贴边或越界禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。",
theme = config.theme_text,
style_clause = style_clause,
item_rows = item_rows,
)
}
fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String {
let base = "文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景";
if !is_match3d_pixel_retro_style(config) {
return base.to_string();
}
format!(
"{base}、抗锯齿、平滑插画、柔焦、软边渐变、矢量扁平插画、真实 3D 渲染、PBR 材质、摄影棚光照"
)
}
fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option<String> {
let prompt = config
.asset_style_prompt
.as_deref()
.or(config.asset_style_label.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
if !is_match3d_pixel_retro_style(config) {
return prompt;
}
Some(match prompt {
Some(prompt) if prompt.contains("禁止抗锯齿") && prompt.contains("64x64") => prompt,
Some(prompt) => format!("{prompt}{MATCH3D_PIXEL_RETRO_STYLE_PROMPT}"),
None => MATCH3D_PIXEL_RETRO_STYLE_PROMPT.to_string(),
})
}
fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool {
config
.asset_style_id
.as_deref()
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("pixel-retro"))
|| config
.asset_style_label
.as_deref()
.map(str::trim)
.is_some_and(|value| value.contains("像素复古"))
}
fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
// 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。
// 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅素材图解码失败:{error}"),
}))
})?;
// 中文注释:素材图按绿幕背景生成;先把整张 sheet 的绿幕转成 alpha再进入格子裁切。
let source = apply_match3d_material_green_screen_alpha(source);
let (width, height) = source.dimensions();
let row_count = MATCH3D_MATERIAL_GRID_SIZE;
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
let cell_height = height / row_count;
if cell_width == 0 || cell_height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": "抓大鹅素材图尺寸过小,无法切割",
})),
);
}
let mut slices = Vec::with_capacity(item_names.len());
for item_index in 0..item_names.len().min(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) {
let row = item_index as u32;
let mut views = Vec::with_capacity(MATCH3D_ITEM_VIEW_COUNT);
for view_index in 0..MATCH3D_ITEM_VIEW_COUNT {
let col = view_index as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_match3d_material_cell_crop(&source, row_count, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅素材图切割失败:{error}"),
}))
})?;
views.push(Match3DSlicedItemImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
fn resolve_match3d_material_cell_crop(
source: &image::DynamicImage,
row_count: u32,
row: u32,
col: u32,
) -> (u32, u32, u32, u32) {
let (image_width, image_height) = source.dimensions();
let cell = resolve_match3d_material_cell_bounds(image_width, image_height, row_count, row, col);
let Some(foreground) = detect_match3d_material_foreground_bounds(source, cell) else {
return cell.to_crop_tuple();
};
let cell_width = cell.width();
let cell_height = cell.height();
let pad_x = (cell_width / 16).clamp(4, 16);
let pad_y = (cell_height / 16).clamp(4, 16);
let crop = Match3DMaterialCellBounds {
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
};
crop.to_crop_tuple()
}
#[derive(Clone, Copy, Debug)]
struct Match3DMaterialCellBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl Match3DMaterialCellBounds {
fn width(self) -> u32 {
self.x1.saturating_sub(self.x0).max(1)
}
fn height(self) -> u32 {
self.y1.saturating_sub(self.y0).max(1)
}
fn area(self) -> u32 {
self.width().saturating_mul(self.height())
}
fn to_crop_tuple(self) -> (u32, u32, u32, u32) {
(self.x0, self.y0, self.width(), self.height())
}
}
fn resolve_match3d_material_cell_bounds(
image_width: u32,
image_height: u32,
row_count: u32,
row: u32,
col: u32,
) -> Match3DMaterialCellBounds {
let normalized_rows = row_count.clamp(1, MATCH3D_MATERIAL_GRID_SIZE);
let cell_x0 = col.saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE;
let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE;
let cell_y0 = row.saturating_mul(image_height) / normalized_rows;
let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_rows;
Match3DMaterialCellBounds {
x0: cell_x0.min(image_width.saturating_sub(1)),
y0: cell_y0.min(image_height.saturating_sub(1)),
x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width),
y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height),
}
}
fn detect_match3d_material_foreground_bounds(
source: &image::DynamicImage,
cell: Match3DMaterialCellBounds,
) -> Option<Match3DMaterialCellBounds> {
let background = sample_match3d_material_cell_background(source, cell);
let mut foreground: Option<Match3DMaterialCellBounds> = None;
let mut foreground_pixels = 0u32;
for y in cell.y0..cell.y1 {
for x in cell.x0..cell.x1 {
if !is_match3d_material_foreground_pixel(source.get_pixel(x, y).0, background) {
continue;
}
foreground_pixels = foreground_pixels.saturating_add(1);
foreground = Some(match foreground {
Some(bounds) => Match3DMaterialCellBounds {
x0: bounds.x0.min(x),
y0: bounds.y0.min(y),
x1: bounds.x1.max(x.saturating_add(1)),
y1: bounds.y1.max(y.saturating_add(1)),
},
None => Match3DMaterialCellBounds {
x0: x,
y0: y,
x1: x.saturating_add(1),
y1: y.saturating_add(1),
},
});
}
}
let min_foreground_pixels = (cell.area() / 320).clamp(12, 220);
foreground.filter(|bounds| {
foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2
})
}
fn sample_match3d_material_cell_background(
source: &image::DynamicImage,
cell: Match3DMaterialCellBounds,
) -> [u8; 4] {
let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8);
let sample_points = [
(cell.x0, cell.y0),
(cell.x1.saturating_sub(sample_size), cell.y0),
(cell.x0, cell.y1.saturating_sub(sample_size)),
(
cell.x1.saturating_sub(sample_size),
cell.y1.saturating_sub(sample_size),
),
];
let mut samples = Vec::new();
for (start_x, start_y) in sample_points {
let mut totals = [0u32; 4];
let mut count = 0u32;
for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) {
for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) {
let pixel = source.get_pixel(x, y).0;
totals[0] = totals[0].saturating_add(pixel[0] as u32);
totals[1] = totals[1].saturating_add(pixel[1] as u32);
totals[2] = totals[2].saturating_add(pixel[2] as u32);
totals[3] = totals[3].saturating_add(pixel[3] as u32);
count = count.saturating_add(1);
}
}
if count > 0 {
samples.push([
(totals[0] / count) as u8,
(totals[1] / count) as u8,
(totals[2] / count) as u8,
(totals[3] / count) as u8,
]);
}
}
samples
.into_iter()
.min_by_key(|sample| {
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
(sample[3] as u16, u16::MAX.saturating_sub(luminance))
})
.unwrap_or([255, 255, 255, 255])
}
fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
let alpha_diff = pixel[3] as i32 - background[3] as i32;
if alpha_diff.abs() >= MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 {
return true;
}
if pixel[3] <= 24 {
return false;
}
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
+ (pixel[1] as i32 - background[1] as i32).abs()
+ (pixel[2] as i32 - background[2] as i32).abs();
color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD
}
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
remove_match3d_material_green_screen_background(
image.as_mut(),
width as usize,
height as usize,
);
image::DynamicImage::ImageRgba8(image)
}
fn remove_match3d_material_green_screen_background(
pixels: &mut [u8],
width: usize,
height: usize,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut green_scores = vec![0.0f32; pixel_count];
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
green_scores[pixel_index] = compute_match3d_material_green_screen_score([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]);
}
let seed_background_pixel =
|pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let alpha = pixels[pixel_index * 4 + 3];
if alpha > 24 && green_scores[pixel_index] < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE {
return;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
};
for x in 0..width {
seed_background_pixel(x, &mut background_mask, &mut queue);
seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
}
for y in 1..height.saturating_sub(1) {
seed_background_pixel(y * width, &mut background_mask, &mut queue);
seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue);
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbor_indexes = [
if x > 0 { Some(pixel_index - 1) } else { None },
if x + 1 < width {
Some(pixel_index + 1)
} else {
None
},
if y > 0 {
Some(pixel_index - width)
} else {
None
},
if y + 1 < height {
Some(pixel_index + width)
} else {
None
},
];
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let alpha = pixels[next_pixel_index * 4 + 3];
let score = green_scores[next_pixel_index];
if alpha <= 24 || score >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE {
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
// 中文注释:主体边缘常带一圈绿幕抗锯齿,扩一层软绿边,避免切割后残留绿色毛边。
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0
|| green_scores[pixel_index] < MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE
{
continue;
}
let mut adjacent_background_count = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0
|| next_x >= width as i32
|| next_y < 0
|| next_y >= height as i32
{
adjacent_background_count += 1;
continue;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
adjacent_background_count += 1;
}
}
}
if adjacent_background_count >= 2
|| (adjacent_background_count >= 1
&& green_scores[pixel_index] >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE)
{
expanded_mask[pixel_index] = 1;
}
}
}
background_mask = expanded_mask;
}
let mut changed = false;
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 {
continue;
}
let alpha_offset = pixel_index * 4 + 3;
if pixels[alpha_offset] != 0 {
pixels[alpha_offset] = 0;
changed = true;
}
}
changed
}
fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let red = pixel[0] as f32;
let green = pixel[1] as f32;
let blue = pixel[2] as f32;
let green_lead = green - red.max(blue);
if green < 96.0 || green_lead <= 18.0 {
return 0.0;
}
let green_ratio = green / (red + blue).max(1.0);
if green_ratio <= 0.9 {
return 0.0;
}
(((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34
+ ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46
+ ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20)
.clamp(0.0, 1.0)
}
#[allow(clippy::too_many_arguments)]
async fn persist_match3d_generated_bytes(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
path_segments: &[&str],
file_name: &str,
content_type: &str,
bytes: Vec<u8>,
asset_kind: &str,
source_job_id: Option<&str>,
generated_at_micros: i64,
) -> Result<Match3DAssetUpload, AppError> {
let oss_client = require_match3d_oss_client(state)?;
let mut metadata = BTreeMap::new();
metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string());
metadata.insert(
"x-oss-meta-owner-user-id".to_string(),
owner_user_id.to_string(),
);
metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string());
if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) {
metadata.insert(
"x-oss-meta-source-job-id".to_string(),
source_job_id.to_string(),
);
}
let oss_http_client = reqwest::Client::builder()
.timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS))
.build()
.map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?;
let put_result = oss_client
.put_object(
&oss_http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Match3DAssets,
path_segments: std::iter::once(session_id)
.chain(std::iter::once(profile_id))
.chain(path_segments.iter().copied())
.map(|segment| sanitize_match3d_asset_segment(segment, "asset"))
.collect(),
file_name: file_name.to_string(),
content_type: Some(content_type.to_string()),
access: OssObjectAccess::Private,
metadata,
body: bytes,
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let _ = generated_at_micros;
Ok(Match3DAssetUpload {
src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
state
.oss_client()
.ok_or_else(|| match3d_oss_config_error(&state.config))
}
fn match3d_oss_config_error(config: &AppConfig) -> AppError {
let missing = missing_match3d_oss_env_keys(config);
let reason = match3d_oss_missing_reason(&missing);
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": reason,
"missingEnv": missing,
}))
}
fn match3d_oss_missing_reason(missing: &[&str]) -> String {
if missing.is_empty() {
"OSS 未完成环境变量配置".to_string()
} else {
format!("OSS 未完成环境变量配置,缺少:{}", missing.join(", "))
}
}
fn missing_match3d_oss_env_keys(config: &AppConfig) -> Vec<&'static str> {
[
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()),
(
"ALIYUN_OSS_ACCESS_KEY_ID",
config.oss_access_key_id.as_deref(),
),
(
"ALIYUN_OSS_ACCESS_KEY_SECRET",
config.oss_access_key_secret.as_deref(),
),
]
.into_iter()
.filter_map(|(name, value)| match value {
Some(value) if !value.trim().is_empty() => None,
_ => Some(name),
})
.collect()
}
fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(64).collect()
}
}
fn normalize_match3d_run_status(value: &str) -> &str {
match value {
"Running" => "running",
"Won" => "won",
"Failed" => "failed",
"Stopped" => "stopped",
_ => value,
}
}
fn normalize_match3d_item_state(value: &str) -> &str {
match value {
"InBoard" => "in_board",
"InTray" => "in_tray",
"Cleared" => "cleared",
_ => value,
}
}
fn normalize_match3d_failure_reason(value: &str) -> &str {
match value {
"TimeUp" => "time_up",
"TrayFull" => "tray_full",
_ => value,
}
}
fn normalize_match3d_click_reject_reason(value: &str) -> &str {
match value {
"RejectedNotClickable" => "item_not_clickable",
"RejectedAlreadyMoved" => "item_not_in_board",
"RejectedTrayFull" => "tray_full",
"VersionConflict" => "snapshot_version_mismatch",
"RunFinished" => "run_not_active",
_ => value,
}
}
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
value: &str,
field_name: &str,
) -> Result<(), Response> {
if value.trim().is_empty() {
return Err(match3d_bad_request(
request_context,
provider,
format!("{field_name} is required").as_str(),
));
}
Ok(())
}
fn match3d_json<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
provider: &str,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
match3d_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": error.body_text(),
})),
)
})
}
fn match3d_bad_request(
request_context: &RequestContext,
provider: &str,
message: &str,
) -> Response {
match3d_error_response(
request_context,
provider,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"message": message,
})),
)
}
fn map_match3d_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("发布需要")
|| message.contains("不能为空")
|| message.contains("必须") =>
{
StatusCode::BAD_REQUEST
}
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn match3d_error_response(
request_context: &RequestContext,
provider: &str,
error: AppError,
) -> Response {
let mut response = error.into_response_with_context(Some(request_context));
response.headers_mut().insert(
HeaderName::from_static("x-genarrative-provider"),
header::HeaderValue::from_str(provider)
.unwrap_or_else(|_| header::HeaderValue::from_static("match3d")),
);
response
}
fn match3d_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
}))
})
}
fn match3d_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match match3d_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(error) => Event::default().event("error").data(format!("{error:?}")),
}
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
}
fn current_utc_ms() -> i64 {
current_utc_micros().saturating_div(1000)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson {
Match3DConfigJson {
theme_text: theme_text.to_string(),
reference_image_src: None,
clear_count,
difficulty,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
#[test]
fn match3d_agent_reply_asks_three_questions_before_confirmation() {
let current = config("水果", 4, 6);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 0),
MATCH3D_QUESTION_THEME
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 1),
MATCH3D_QUESTION_CLEAR_COUNT
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 2),
MATCH3D_QUESTION_DIFFICULTY
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 3),
"已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。"
);
}
#[test]
fn match3d_agent_progress_follows_question_turns() {
assert_eq!(resolve_progress_percent_for_turn(0), 0);
assert_eq!(resolve_progress_percent_for_turn(1), 33);
assert_eq!(resolve_progress_percent_for_turn(2), 66);
assert_eq!(resolve_progress_percent_for_turn(3), 100);
assert_eq!(resolve_progress_percent_for_turn(8), 100);
}
#[test]
fn match3d_anchor_pack_masks_uncollected_default_values() {
let pack = Match3DAnchorPackRecord {
theme: Match3DAnchorItemRecord {
key: "theme".to_string(),
label: "题材主题".to_string(),
value: "缤纷玩具".to_string(),
status: "confirmed".to_string(),
},
clear_count: Match3DAnchorItemRecord {
key: "clearCount".to_string(),
label: "需要消除次数".to_string(),
value: "12".to_string(),
status: "confirmed".to_string(),
},
difficulty: Match3DAnchorItemRecord {
key: "difficulty".to_string(),
label: "难度".to_string(),
value: "4".to_string(),
status: "confirmed".to_string(),
},
};
let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting");
assert_eq!(response.theme.value, "");
assert_eq!(response.theme.status, "missing");
assert_eq!(response.clear_count.value, "");
assert_eq!(response.clear_count.status, "missing");
assert_eq!(response.difficulty.value, "");
assert_eq!(response.difficulty.status, "missing");
}
#[test]
fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
let item_names = ["草莓", "苹果", "香蕉"];
let slugs = item_names
.iter()
.enumerate()
.map(|(index, item_name)| {
let item_id = format!("match3d-item-{}", index + 1);
format!(
"{item_id}-{}",
sanitize_match3d_asset_segment(item_name, "item")
)
})
.collect::<Vec<_>>();
assert_eq!(
slugs,
vec![
"match3d-item-1-item",
"match3d-item-2-item",
"match3d-item-3-item",
]
);
}
#[test]
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let width = 500;
let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::new(width, height);
for row in 0..5 {
for col in 0..5 {
let color = image::Rgba([
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
255,
]);
for y in row * 100..(row + 1) * 100 {
for x in col * 100..(col + 1) * 100 {
sheet.put_pixel(x, y, color);
}
}
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
assert_eq!(slices.len(), 3);
for (row, views) in slices.iter().enumerate() {
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
for (col, view) in views.iter().enumerate() {
let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode")
.to_rgba8();
let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2);
assert_eq!(
pixel.0,
[
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
255,
],
"row {row} col {col} should be cut from the fixed 5*5 grid row"
);
}
}
}
#[test]
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
let width = 500;
let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet =
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
for y in 1..5 {
for x in 18..82 {
sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255]));
}
}
for y in 5..96 {
for x in 18..82 {
sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
for y in 96..99 {
for x in 18..82 {
sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
let decoded = image::load_from_memory(slices[0][0].bytes.as_slice())
.expect("view should decode")
.to_rgba8();
let pixels = decoded.pixels().map(|pixel| pixel.0).collect::<Vec<_>>();
assert!(
pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]),
"贴近顶部的前景像素不能被固定内缩切掉"
);
assert!(
pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]),
"贴近底部的前景像素不能被固定内缩切掉"
);
}
#[test]
fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() {
let width = 500;
let height = 500;
let item_names = vec!["草莓".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 35..65 {
for x in 35..65 {
sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
let decoded = image::load_from_memory(slices[0][0].bytes.as_slice())
.expect("view should decode")
.to_rgba8();
assert_eq!(
decoded.get_pixel(0, 0).0[3],
0,
"绿幕背景必须在切割输出中变成透明 alpha"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]),
"物品主体不能被绿幕去背误删"
);
}
#[test]
fn match3d_background_music_ready_requires_audio_src() {
let mut asset = Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: Some("果园轻舞".to_string()),
background_music_style: Some("轻快, 休闲".to_string()),
background_music_prompt: Some(String::new()),
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
};
assert!(
!has_match3d_background_music_audio(&asset),
"只有音乐元信息时不能把草稿音乐阶段视为完成"
);
asset.background_music = Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("match3d_background_music".to_string()),
audio_src: "/generated-match3d-assets/music.mp3".to_string(),
prompt: Some(String::new()),
title: Some("果园轻舞".to_string()),
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
});
assert!(has_match3d_background_music_audio(&asset));
}
#[test]
fn match3d_background_music_missing_error_lists_required_asset() {
let error = match3d_background_music_missing_error("抓大鹅草稿背景音乐名称为空");
let body = error.body_text();
assert!(body.contains("抓大鹅草稿背景音乐名称为空"));
assert!(body.contains("背景音乐"));
}
#[test]
fn match3d_background_music_title_is_required_for_auto_draft() {
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " ,。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
assert!(missing.body_text().contains("背景音乐"));
let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
title: " 果园轻舞。 ".to_string(),
style: "轻快, 休闲".to_string(),
prompt: String::new(),
})
.expect("valid title should pass");
assert_eq!(title, "果园轻舞");
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#,
)
.expect("metadata should parse");
assert_eq!(metadata.game_name, "果园大鹅宴");
assert_eq!(
metadata.summary,
"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。"
);
assert_eq!(
metadata.tags,
vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"]
);
}
#[test]
fn match3d_work_metadata_fallback_keeps_empty_description_boundary() {
let metadata = fallback_match3d_work_metadata("水果");
assert_eq!(metadata.game_name, "水果抓大鹅");
assert!(metadata.summary.contains("水果主题"));
assert!(metadata.tags.contains(&"水果".to_string()));
assert!(metadata.tags.contains(&"抓大鹅".to_string()));
}
#[test]
fn match3d_draft_plan_parses_audio_prompts() {
let plan = parse_match3d_draft_plan(
r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#,
&config("水果", 3, 3),
)
.expect("draft plan should parse");
assert_eq!(plan.metadata.game_name, "果园大鹅宴");
assert_eq!(
plan.metadata.summary,
"明亮果园里堆满水果小物,轻快收集感突出。"
);
assert_eq!(plan.background_music.title, "果园轻舞");
assert!(plan.background_music.prompt.is_empty());
assert!(plan.background_prompt.contains("纯背景"));
assert_eq!(plan.items[0].name, "草莓");
assert!(plan.items[0].sound_prompt.contains("草莓"));
}
#[test]
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
let plan = parse_match3d_draft_plan(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
&config("水果", 12, 4),
)
.expect("draft plan should parse");
assert_eq!(plan.items.len(), 10);
assert_eq!(plan.items[8].name, "蓝莓");
assert_ne!(plan.items[9].name, "蓝莓");
}
#[test]
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
5
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
10
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
15
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
25
);
}
#[test]
fn match3d_item_asset_points_cost_counts_five_item_batches() {
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
}
#[test]
fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
let existing_assets = vec![Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
}];
let plan = build_match3d_item_asset_append_plan(
vec![
"草莓".to_string(),
"苹果".to_string(),
"香蕉".to_string(),
"梨子".to_string(),
],
&existing_assets,
);
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
assert_eq!(plan.padded_item_names.len(), 5);
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
assert_eq!(
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
2
);
}
#[test]
fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() {
let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT)
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("已有物品{index}"),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
let plan =
build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets);
assert_eq!(plan.requested_item_names, vec!["新物品"]);
assert_eq!(
plan.padded_item_names.len(),
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
);
assert_eq!(plan.padded_item_names[0], "新物品");
}
#[test]
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
let prompt = build_match3d_material_sheet_prompt(
&config("水果", 12, 4),
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("严格5*5均匀排布"));
assert!(prompt.contains("绿幕背景"));
assert!(prompt.contains("#00FF00"));
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
assert!(prompt.contains("约25%单格宽度"));
assert!(prompt.contains("禁止主体跨格"));
assert!(prompt.contains("贴边或越界"));
}
#[test]
fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
let mut config = config("水果", 12, 4);
config.asset_style_id = Some("pixel-retro".to_string());
config.asset_style_label = Some("像素复古".to_string());
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
assert!(prompt.contains("64x64"));
assert!(prompt.contains("整数倍放大"));
assert!(prompt.contains("禁止抗锯齿"));
assert!(prompt.contains("真实 3D 渲染"));
assert!(prompt.contains("PBR 材质"));
assert!(negative_prompt.contains("抗锯齿"));
assert!(negative_prompt.contains("平滑插画"));
assert!(negative_prompt.contains("真实 3D 渲染"));
}
#[test]
fn match3d_material_sheet_request_uses_apimart_nanobanana_contract() {
let body = build_match3d_apimart_nanobanana_image_request_body(
"生成水果素材图",
"文字、水印",
MATCH3D_MATERIAL_APIMART_SIZE,
);
assert_eq!(body["model"], MATCH3D_MATERIAL_APIMART_MODEL);
assert_eq!(body["size"], MATCH3D_MATERIAL_APIMART_SIZE);
assert_eq!(body["resolution"], MATCH3D_MATERIAL_APIMART_RESOLUTION);
assert_eq!(body["n"], 1);
assert_eq!(body["official_fallback"], true);
assert!(body.get("image").is_none());
assert!(body.get("image_urls").is_none());
assert!(
body["prompt"]
.as_str()
.unwrap_or_default()
.contains("文字、水印")
);
}
#[test]
fn match3d_background_and_container_prompts_keep_ui_layers_split() {
let config = config("水果", 3, 3);
let background_prompt =
build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景");
let container_prompt =
build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景");
assert!(background_prompt.contains("9:16"));
assert!(background_prompt.contains("纯背景图"));
assert!(background_prompt.contains("不得出现锅"));
assert!(background_prompt.contains("拼图槽"));
assert!(background_prompt.contains("物品槽"));
assert!(background_prompt.contains("默认交互容器"));
assert!(container_prompt.contains("1:1"));
assert!(container_prompt.contains("中心容器 UI 图"));
assert!(container_prompt.contains("贴合题材设定"));
assert!(container_prompt.contains("占画布宽度约 86%-92%"));
assert!(container_prompt.contains("轻俯视 3/4"));
assert!(container_prompt.contains("横向椭圆形内口"));
assert!(container_prompt.contains("不能画成正俯视扁圆盘"));
assert!(container_prompt.contains("不能做成整页背景"));
assert!(container_prompt.contains("禁止文字"));
}
#[test]
fn match3d_background_asset_requires_background_and_container_images() {
let background_only = Match3DGeneratedBackgroundAsset {
prompt: "果园背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background/bg.png".to_string(),
),
image_object_key: None,
container_prompt: None,
container_image_src: None,
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
container_image_src: Some(
"/generated-match3d-assets/session/profile/ui-container/container.png".to_string(),
),
..background_only.clone()
};
assert!(!is_match3d_background_asset_ready(&background_only));
assert!(is_match3d_background_asset_ready(&with_container));
}
#[test]
fn match3d_default_cover_prefers_generated_container_ui_image() {
let assets = vec![Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: None,
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "果园背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
image_object_key: None,
container_prompt: Some("果园容器".to_string()),
container_image_src: Some(
"/generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
}),
status: "image_ready".to_string(),
error: None,
}];
assert_eq!(
resolve_match3d_default_cover_image_src(&assets).as_deref(),
Some("/generated-match3d-assets/session/profile/ui-container/container.png")
);
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲");
}
#[test]
fn match3d_plan_tags_are_kept_before_local_fallback_tags() {
let tags = merge_match3d_plan_tags_with_fallback(
"果园大鹅宴",
"水果",
&["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()],
);
assert_eq!(tags[0], "果园");
assert_eq!(tags[1], "轻快");
assert_eq!(tags[2], "抓大鹅");
assert!(tags.contains(&"水果".to_string()));
assert!(tags.contains(&"经典消除".to_string()));
}
#[test]
fn match3d_model_download_metadata_normalizes_to_glb() {
assert_eq!(
normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"),
"fruit-model.glb"
);
assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb");
assert_eq!(
normalize_match3d_model_content_type("application/octet-stream"),
"model/gltf-binary"
);
assert_eq!(
normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"),
"model/gltf-binary"
);
}
#[test]
fn match3d_model_download_requires_valid_glb_header() {
let mut glb = Vec::new();
glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes());
glb.extend_from_slice(&2_u32.to_le_bytes());
glb.extend_from_slice(&12_u32.to_le_bytes());
assert!(is_match3d_glb_binary_payload(&glb));
assert!(!is_match3d_glb_binary_payload(b"<html>expired</html>"));
let mut wrong_length = glb.clone();
wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes());
assert!(!is_match3d_glb_binary_payload(&wrong_length));
}
#[test]
fn match3d_generated_asset_resume_keeps_stable_item_order() {
let assets = normalize_match3d_generated_item_assets_for_resume(vec![
Match3DGeneratedItemAsset {
item_id: "match3d-item-2".to_string(),
item_name: "苹果".to_string(),
image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()),
image_object_key: Some(
"generated-match3d-assets/s/p/items/i2/image.png".to_string(),
),
image_views: Vec::new(),
model_src: Some(
"/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(),
),
model_object_key: Some(
"generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(),
),
model_file_name: Some("model.glb".to_string()),
task_uuid: Some("task-2".to_string()),
subscription_key: Some("sub-2".to_string()),
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "model_ready".to_string(),
error: None,
},
Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()),
image_object_key: Some(
"generated-match3d-assets/s/p/items/i1/image.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
},
]);
assert_eq!(assets[0].item_id, "match3d-item-1");
assert_eq!(assets[1].item_id, "match3d-item-2");
}
#[test]
fn match3d_required_item_images_require_five_views() {
let assets = vec![
Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()),
image_object_key: Some(
"generated-match3d-assets/s/p/items/i1/image.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
},
Match3DGeneratedItemAsset {
item_id: "match3d-item-2".to_string(),
item_name: "苹果".to_string(),
image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()),
image_object_key: Some(
"generated-match3d-assets/s/p/items/i2/image.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
},
Match3DGeneratedItemAsset {
item_id: "match3d-item-3".to_string(),
item_name: "香蕉".to_string(),
image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()),
image_object_key: None,
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
},
];
assert!(!has_match3d_required_item_images(&assets, 3));
let five_view_assets = (1..=3)
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("物品{index}"),
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
.map(|view_index| Match3DGeneratedItemImageView {
view_id: format!("view-{view_index:02}"),
view_index: view_index as u32,
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
})
.collect(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
assert!(has_match3d_required_item_images(&five_view_assets, 3));
}
#[test]
fn match3d_oss_config_error_lists_missing_env_keys() {
let mut app_config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
..AppConfig::default()
};
let missing = missing_match3d_oss_env_keys(&app_config);
assert_eq!(
missing,
vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"]
);
assert_eq!(
match3d_oss_missing_reason(&missing),
"OSS 未完成环境变量配置缺少ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET"
);
app_config.oss_access_key_id = Some("ak".to_string());
app_config.oss_access_key_secret = Some("sk".to_string());
assert!(missing_match3d_oss_env_keys(&app_config).is_empty());
}
#[test]
fn match3d_work_summary_maps_persisted_generated_item_assets() {
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"#
.to_string(),
),
});
assert_eq!(response.generated_item_assets.len(), 1);
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
assert_eq!(response.generated_item_assets[0].status, "image_ready");
assert_eq!(
response.generated_item_assets[0].image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
);
}
}