Files
Genarrative/server-rs/crates/api-server/src/match3d.rs
高物 b13870f71b
Some checks failed
CI / verify (pull_request) Waiting to run
CI / verify (push) Has been cancelled
1
2026-05-13 03:11:00 +08:00

5101 lines
171 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,
time::{Duration, SystemTime, UNIX_EPOCH},
};
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::{
hyper3d as hyper3d_contract,
creation_audio::CreationAudioAsset,
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,
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, 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_MATERIAL_ITEM_BATCH_SIZE: usize = 5;
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 21;
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_BACKGROUND_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";
#[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>,
status: String,
#[serde(default)]
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedWorkMetadata {
game_name: 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,
}
#[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>,
}
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 context =
load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
.await?;
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
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
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?;
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 generated_assets = append_match3d_item_assets(
&state,
&request_context,
&authenticated,
context.owner_user_id.as_str(),
context.session_id.as_str(),
profile_id.as_str(),
&context.config,
item_names,
context.assets,
)
.await?;
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(),
&generated_assets,
)
.await?;
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),
)
})?;
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(),
)
.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).or_else(|| Some(String::new()));
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
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 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(),
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
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 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_tags = requested_tags.unwrap_or_else(|| generated_work_metadata.metadata.tags.clone());
session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id.clone(),
profile_id.clone(),
Some(resolved_game_name),
requested_summary,
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
cover_image_src,
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?;
session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session.session_id.clone(),
owner_user_id.clone(),
profile_id,
None,
None,
None,
None,
None,
serialize_match3d_generated_item_assets(&generated_item_assets),
)
.await?;
Ok((session, generated_item_assets))
}
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 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();
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,
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,
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 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 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,
) -> Vec<String> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_work_tags(game_name, theme_text);
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text, game_name
);
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 tags;
}
fallback_match3d_work_tags(game_name, theme_text)
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
fallback_match3d_work_tags(game_name, theme_text)
}
}
}
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,
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(|asset| asset.background_music.is_some())
{
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(),
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 {
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,
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>)>,
}
#[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<Match3DGeneratedItemAsset>, Response> {
if item_seeds.is_empty() {
return Ok(Vec::new());
}
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 item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names)?;
Ok::<_, AppError>(Match3DMaterialBatchOutput {
task_id: material_sheet.task_id,
generated_at_micros,
items: chunk_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(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,
});
}
}
Ok(sort_match3d_generated_assets(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 Ok(assets);
};
let title = normalize_match3d_audio_title(plan.title.as_str());
if title.is_empty() {
return Ok(assets);
}
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::warn!(
provider = MATCH3D_AGENT_PROVIDER,
session_id,
profile_id,
error = %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,
item_names: Vec<String>,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = sort_match3d_generated_assets(existing_assets);
let new_item_names = item_names
.into_iter()
.filter(|name| {
!assets
.iter()
.any(|asset| asset.item_name.trim() == name.trim())
})
.collect::<Vec<_>>();
if new_item_names.is_empty() {
return Ok(assets);
}
let mut next_item_index = next_match3d_generated_item_index(&assets);
let item_seeds = new_item_names
.into_iter()
.map(|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,
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 {
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
}
struct Match3DMaterialSheet {
task_id: String,
image: DownloadedOpenAiImage,
}
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 target_item_count = resolve_match3d_generated_item_count(config);
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、tags、backgroundMusic、backgroundPrompt、items。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字backgroundMusic 包含 title、style、prompttitle 是本作品背景音乐名称且不超过 40 个中文字符style 是逗号分隔的音乐风格prompt 必须返回空字符串backgroundPrompt 是用于生成局内背景图的中文提示词,必须描述竖屏移动端抓大鹅画面,绿色纵向渐变背景与居中浅锅/圆盘状竞技区域融合为一张完整背景图,无 UI、无文字、无按钮、无倒计时、无物品items 必须正好 {} 项,每项包含 name 和 soundPromptname 为 2 到 6 个汉字soundPrompt 是用于生成物品点击消除音效的中文提示词。不要生成描述。",
config.theme_text, target_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 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,
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 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}抓大鹅"),
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()
.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;
}
}
}
normalized
}
fn normalize_match3d_batch_item_names(items: Vec<String>) -> Vec<String> {
let mut normalized = 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 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_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_generated_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 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_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())
}
#[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> {
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> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_background_reference_data_url().await?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_match3d_background_generation_prompt(config, prompt).as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单"),
"9:16",
1,
&[reference_image],
"抓大鹅背景图生成失败",
)
.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 upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["background", generated.task_id.as_str()],
"background.png",
image.mime_type.as_str(),
image.bytes,
"match3d_background_image",
Some(generated.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DGeneratedBackgroundAsset {
prompt: prompt.to_string(),
image_src: Some(upload.src),
image_object_key: Some(upload.object_key),
status: "image_ready".to_string(),
error: None,
})
}
async fn load_match3d_background_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(MATCH3D_BACKGROUND_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}严格参考输入图的构图关系:绿色纵向背景与居中锅状圆形游戏区域融合为一张完整竖屏背景图。画面必须是纯背景和容器空间,中央锅内留出清晰区域给 2D 物品素材叠放,顶部预留少量安全空间给返回、倒计时和重开按钮,但图片内不要画任何 UI。"
)
}
async fn generate_match3d_material_sheet(
state: &AppState,
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、UI、边框、网格线、标签、人物手部、复杂背景"),
"1:1",
1,
&[],
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
image,
})
}
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图片。生成{grid}*{grid}网格素材图,画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格按行组织:{item_rows}。同一行五格必须是同一物品的五个不同视角依次为正面、左前、右前、俯视、背面每个格子一个独立居中的完整物体白色或透明感干净背景统一柔和光照清晰轮廓适合直接切割成游戏2D图标。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。",
grid = MATCH3D_MATERIAL_GRID_SIZE,
theme = config.theme_text,
style_clause = style_clause,
item_rows = item_rows,
)
}
fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option<String> {
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)
}
fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
// 中文注释:每张 1K 素材图固定按 5x5 切割;一行对应一个物品的五个视角。
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}"),
}))
})?;
let (width, height) = source.dimensions();
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
let cell_height = height / MATCH3D_MATERIAL_GRID_SIZE;
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 cropped =
source.crop_imm(col * cell_width, row * cell_height, cell_width, cell_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)
}
#[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 = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
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 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_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
r#"{"gameName":"果园大鹅宴","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.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.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":"果园大鹅宴","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.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_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_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_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")
);
}
}