This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -40,7 +40,7 @@ use shared_contracts::{
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -57,9 +57,10 @@ use spacetime_client::{
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -85,6 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -103,7 +105,7 @@ pub async fn create_puzzle_agent_session(
)
})?;
let seed_text = payload.seed_text.unwrap_or_default().trim().to_string();
let seed_text = build_puzzle_form_seed_text(&payload);
let session = state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
@@ -455,6 +457,8 @@ pub async fn execute_puzzle_agent_action(
&state,
session_id.clone(),
owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
now,
)
.await
@@ -1142,6 +1146,120 @@ pub async fn advance_puzzle_next_level(
))
}
pub async fn update_puzzle_run_pause(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UpdatePuzzleRuntimePauseRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.update_puzzle_run_pause(PuzzleRunPauseRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
paused: payload.paused,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn use_puzzle_runtime_prop(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<UsePuzzleRuntimePropRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
ensure_non_empty(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
&payload.prop_kind,
"propKind",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let prop_kind = payload.prop_kind.trim().to_string();
let billing_asset_kind = match prop_kind.as_str() {
"hint" => "puzzle_prop_hint",
"reference" => "puzzle_prop_preview",
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
_ => {
return Err(puzzle_bad_request(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
"unknown puzzle prop kind",
));
}
};
let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros());
let reducer_owner_user_id = owner_user_id.clone();
let run = execute_billable_asset_operation(
&state,
&owner_user_id,
billing_asset_kind,
billing_asset_id.as_str(),
async {
state
.spacetime_client()
.use_puzzle_runtime_prop(PuzzleRunPropRecordInput {
run_id,
owner_user_id: reducer_owner_user_id,
prop_kind,
used_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)
},
)
.await
.map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
pub async fn advance_local_puzzle_next_level(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1399,6 +1517,7 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
publish_ready: item.publish_ready,
}
}
@@ -1465,6 +1584,13 @@ fn map_puzzle_level_request_record(
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms: level.time_limit_ms,
remaining_ms: level.remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
@@ -1524,6 +1650,18 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle
fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
level.time_limit_ms
};
let remaining_ms =
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
time_limit_ms
} else {
level.remaining_ms.min(time_limit_ms)
};
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
@@ -1538,6 +1676,13 @@ fn map_puzzle_runtime_level_response(
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms,
remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
@@ -1546,6 +1691,17 @@ fn map_puzzle_runtime_level_response(
}
}
struct PuzzleRuntimeTimerResponseDefaults {
time_limit_ms: u64,
}
fn build_puzzle_runtime_timer_response_defaults(
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
@@ -1612,10 +1768,28 @@ fn resolve_author_display_name(
fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点".to_string();
return "拼图创作信息已准备好".to_string();
}
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡".to_string()
"拼图创作信息已准备好".to_string()
}
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
let title = payload.seed_text.as_deref().unwrap_or_default().trim();
let picture_description = payload
.picture_description
.as_deref()
.unwrap_or_default()
.trim();
if title.is_empty() && picture_description.is_empty() {
return String::new();
}
if title.is_empty() || picture_description.is_empty() {
return format!("{title}{picture_description}");
}
format!("拼图标题:{title}\n画面描述:{picture_description}")
}
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
@@ -1632,6 +1806,8 @@ async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let compiled_session = state
@@ -1648,8 +1824,11 @@ async fn compile_puzzle_draft_with_initial_cover(
owner_user_id.as_str(),
&compiled_session.session_id,
&draft.level_name,
&draft.summary,
None,
prompt_text
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(draft.summary.as_str()),
reference_image_src,
1,
draft.candidates.len(),
)
@@ -1815,6 +1994,7 @@ async fn generate_puzzle_image_candidates(
None => None,
};
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。
let generated = match reference_image.as_deref() {
Some(reference_image) => {
create_puzzle_image_to_image_generation(
@@ -1822,7 +2002,7 @@ async fn generate_puzzle_image_candidates(
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
PUZZLE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
@@ -1834,7 +2014,7 @@ async fn generate_puzzle_image_candidates(
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
PUZZLE_GENERATED_IMAGE_SIZE,
count,
)
.await
@@ -2079,6 +2259,7 @@ fn build_next_run_from_parts(
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
let mut played_profile_ids = run.played_profile_ids.clone();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
@@ -2106,6 +2287,13 @@ fn build_next_run_from_parts(
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
time_limit_ms,
remaining_ms: time_limit_ms,
paused_accumulated_ms: 0,
pause_started_at_ms: None,
freeze_accumulated_ms: 0,
freeze_started_at_ms: None,
freeze_until_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
@@ -2221,6 +2409,11 @@ mod tests {
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
#[test]
fn puzzle_generated_image_size_is_portrait_9_16() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "720*1280");
}
}
struct PuzzleDashScopeSettings {