use std::{ convert::Infallible, time::{SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use module_match3d::{ MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, MATCH3D_SESSION_ID_PREFIX, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::{ match3d_agent::{ CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest, Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse, Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse, Match3DCreatorConfigResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest, }, match3d_runtime::{ ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse, Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse, Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest, }, match3d_works::{ Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse, Match3DWorkSummaryResponse, Match3DWorksResponse, 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, request_context::RequestContext, state::AppState, }; 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_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Match3DConfigJson { theme_text: String, reference_image_src: Option, clear_count: u32, difficulty: u32, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CompileMatch3DDraftRequest { #[serde(default)] game_name: Option, #[serde(default)] summary: Option, #[serde(default)] tags: Option>, #[serde(default)] cover_image_src: Option, } pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { 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::(match3d_sse_json_event_or_error( "reply_delta", json!({ "text": reply }), )); } yield Ok::(match3d_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(match3d_sse_json_event_or_error( "done", json!({ "ok": true }), )); } Err(response) => { yield Ok::(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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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 = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response(session), }, )) } pub async fn compile_match3d_agent_draft( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let payload = payload .map(|Json(payload)| payload) .unwrap_or(CompileMatch3DDraftRequest { game_name: None, summary: None, tags: None, cover_image_src: None, }); ensure_non_empty( &request_context, MATCH3D_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = compile_match3d_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, ) .await?; Ok(json_success_body( Some(&request_context), Match3DAgentActionResponse { session: map_match3d_agent_session_response(session), }, )) } pub async fn get_match3d_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Extension(request_context): Extension, ) -> Result, 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, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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 publish_match3d_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); let profile_id = maybe_payload .map(|payload| payload.profile_id) .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, started_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 get_match3d_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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 { 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, summary: Option, tags: Option>, cover_image_src: Option, ) -> Result { let owner_user_id = authenticated.claims().user_id().to_string(); let 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), ) })?; if session.current_turn < 3 || session.progress_percent < 100 { return Err(match3d_bad_request( request_context, MATCH3D_AGENT_PROVIDER, "match3d 创作配置尚未确认完成", )); } let config = resolve_config_or_default(session.config.as_ref()); let tags_json = tags .as_ref() .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); state .spacetime_client() .compile_match3d_draft(Match3DCompileDraftRecordInput { session_id, owner_user_id, profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX), author_display_name: resolve_author_display_name(state, authenticated), game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))), summary_text: summary, tags_json, cover_image_src, cover_asset_id: None, compiled_at_micros: current_utc_micros(), }) .await .map_err(|error| { match3d_error_response( request_context, MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) }) } 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_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, } } 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, } } 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 { 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, } } 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) .max(1), difficulty: payload .difficulty .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) .clamp(1, 10), } } 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); 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, } } 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), }) .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, }) } fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { 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 { 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 { 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 { 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::().ok().filter(|value| *value > 0) } fn normalize_tags(tags: Vec) -> Vec { let mut result = Vec::new(); for tag in tags { let trimmed = tag.trim(); if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { result.push(trimmed.to_string()); } if result.len() >= 6 { break; } } result } 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()) } 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( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, 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::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, } } #[test] fn match3d_agent_reply_asks_three_questions_before_confirmation() { let current = config("水果", 4, 6); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 0), MATCH3D_QUESTION_THEME ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 1), MATCH3D_QUESTION_CLEAR_COUNT ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 2), MATCH3D_QUESTION_DIFFICULTY ); assert_eq!( build_match3d_assistant_reply_for_turn(¤t, 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"); } }