1599 lines
50 KiB
Rust
1599 lines
50 KiB
Rust
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,
|
||
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_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<String>,
|
||
clear_count: u32,
|
||
difficulty: u32,
|
||
}
|
||
|
||
#[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>,
|
||
}
|
||
|
||
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 = 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<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,
|
||
});
|
||
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<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 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
|
||
.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: profile_id.clone(),
|
||
started_at_ms: current_utc_ms(),
|
||
})
|
||
.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>,
|
||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||
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<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::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<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,
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|