From 4e04679ba4036bf30c3881f95f3608bab356918a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 15:55:05 +0800 Subject: [PATCH] fix: move puzzle next level source selection server side --- ...E_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md | 15 +- .../src/contracts/puzzleRuntimeSession.ts | 5 + server-rs/crates/api-server/src/app.rs | 18 +- server-rs/crates/api-server/src/puzzle.rs | 353 +++++++++++++++++- .../shared-contracts/src/puzzle_runtime.rs | 8 + .../PlatformEntryFlowShellImpl.tsx | 156 +------- src/services/puzzle-runtime/index.ts | 1 + .../puzzle-runtime/puzzleLocalRuntime.ts | 8 +- .../puzzle-runtime/puzzleRuntimeClient.ts | 22 ++ 9 files changed, 418 insertions(+), 168 deletions(-) diff --git a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md index 9fb0373d..f945798c 100644 --- a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md +++ b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md @@ -15,7 +15,7 @@ 2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。 3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。 4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。 -5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。 +5. 通关后的第一版接续按“广场作品优先”执行:先从拼图广场读取未玩过且有正式图的作品;广场没有可用作品时,再使用当前草稿期间已生成但未消费的候选图;候选图仍不足时,现场调用 `generate_puzzle_images` 生成候选图,并在运行页弹出等待面板。 6. 后端仍然负责: - Agent 会话 - 结果页草稿编译 @@ -59,8 +59,11 @@ 1. 进入玩法时从作品详情构造本地 `run` 2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run` 3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮 -4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4` -5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 +4. 点击下一关时前端只提交当前 `run` 与可选 `sourceSessionId` 到 Rust `api-server`,不在前端判断图片来源 +5. `api-server` 优先用广场作品详情构造下一关;如果广场没有可用作品,则把草稿候选图包装成一次本地关卡来源 +6. 草稿候选图仍不足时,`api-server` 现场调用真实生图链生成候选图;前端只展示等待弹窗并接收最终 `run` +7. 每次进入下一关都会重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4` +8. 当前不依赖后端 `start/swap/drag/next-level` 接口保存过程状态 ## 5. 当前实现判断标准 @@ -70,5 +73,7 @@ 2. 返回路径切到 `/generated-puzzle-assets/*`。 3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。 4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。 -5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。 -6. 关闭玩法后不保留当前 run 进度。 +5. 玩家完成整张图后能看到通关态与“下一关”入口。 +6. 广场有可用作品时,下一关内容来自广场作品。 +7. 广场没有可用作品时,下一关内容来自草稿候选图;候选图不足时现场生成并显示等待面板。 +8. 关闭玩法后不保留当前 run 进度。 diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index de8cf815..22ebcf83 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -58,6 +58,11 @@ export interface StartPuzzleRunRequest { profileId: string; } +export interface AdvanceLocalPuzzleNextLevelRequest { + run: PuzzleRunSnapshot; + sourceSessionId?: string | null; +} + export interface PuzzleRunResponse { run: PuzzleRunSnapshot; } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 7e19a372..6305d190 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -77,11 +77,12 @@ use crate::{ password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, puzzle::{ - advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work, - drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session, - get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, - list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, - submit_puzzle_agent_message, swap_puzzle_pieces, + advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, + delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, + get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, + get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, + start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, + swap_puzzle_pieces, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -626,6 +627,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/runs/local-next-level", + post(advance_local_puzzle_next_level).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs/{run_id}", get(get_puzzle_run).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 5a216bd5..c2a2bb33 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -32,10 +32,10 @@ use shared_contracts::{ }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ - DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, - PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse, - PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, - SwapPuzzlePiecesRequest, + AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, + PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, + PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, + StartPuzzleRunRequest, SwapPuzzlePiecesRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -50,9 +50,10 @@ use spacetime_client::{ PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -1063,6 +1064,36 @@ pub async fn advance_puzzle_next_level( )) } +pub async fn advance_local_puzzle_next_level( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, 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(), + })), + ) + })?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let run = build_local_next_puzzle_run(&state, payload, owner_user_id.as_str()) + .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), + }, + )) +} + fn map_puzzle_agent_session_response( session: PuzzleAgentSessionRecord, ) -> PuzzleAgentSessionSnapshotResponse { @@ -1265,6 +1296,74 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { } } +fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord { + PuzzleRunRecord { + run_id: run.run_id, + entry_profile_id: run.entry_profile_id, + cleared_level_count: run.cleared_level_count, + current_level_index: run.current_level_index, + current_grid_size: run.current_grid_size, + played_profile_ids: run.played_profile_ids, + previous_level_tags: run.previous_level_tags, + current_level: run.current_level.map(map_puzzle_level_request_record), + recommended_next_profile_id: run.recommended_next_profile_id, + } +} + +fn map_puzzle_level_request_record( + level: PuzzleRuntimeLevelSnapshotResponse, +) -> PuzzleRuntimeLevelRecord { + PuzzleRuntimeLevelRecord { + run_id: level.run_id, + level_index: level.level_index, + grid_size: level.grid_size, + profile_id: level.profile_id, + level_name: level.level_name, + author_display_name: level.author_display_name, + theme_tags: level.theme_tags, + cover_image_src: level.cover_image_src, + board: map_puzzle_board_request_record(level.board), + status: level.status, + } +} + +fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> PuzzleBoardRecord { + PuzzleBoardRecord { + rows: board.rows, + cols: board.cols, + pieces: board + .pieces + .into_iter() + .map(|piece| PuzzlePieceStateRecord { + piece_id: piece.piece_id, + correct_row: piece.correct_row, + correct_col: piece.correct_col, + current_row: piece.current_row, + current_col: piece.current_col, + merged_group_id: piece.merged_group_id, + }) + .collect(), + merged_groups: board + .merged_groups + .into_iter() + .map(|group| PuzzleMergedGroupRecord { + group_id: group.group_id, + piece_ids: group.piece_ids, + occupied_cells: group + .occupied_cells + .into_iter() + .map(|cell| PuzzleCellPositionRecord { + row: cell.row, + col: cell.col, + }) + .collect(), + }) + .collect(), + selected_piece_id: board.selected_piece_id, + all_tiles_resolved: board.all_tiles_resolved, + } +} + fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { @@ -1574,6 +1673,248 @@ async fn generate_puzzle_image_candidates( .collect()) } +async fn build_local_next_puzzle_run( + state: &AppState, + payload: AdvanceLocalPuzzleNextLevelRequest, + owner_user_id: &str, +) -> Result { + let run = map_puzzle_run_request_record(payload.run); + let current_level = run.current_level.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "currentLevel is required", + })) + })?; + if current_level.status != "cleared" { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "current level is not cleared", + }))); + } + + if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { + return Ok(build_next_run_from_puzzle_work(run, gallery_item)); + } + + let source_session_id = payload.source_session_id.unwrap_or_default(); + if source_session_id.trim().is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "sourceSessionId is required when gallery has no next puzzle work", + }))); + } + let session = state + .spacetime_client() + .get_puzzle_agent_session(source_session_id, owner_user_id.to_string()) + .await + .map_err(map_puzzle_client_error)?; + if let Some(candidate) = session + .draft + .as_ref() + .and_then(|draft| pick_unused_puzzle_candidate(&draft.candidates, &run.played_profile_ids)) + { + return Ok(build_next_run_from_candidate(run, &session, candidate)); + } + + let draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "puzzle draft is required when gallery has no next puzzle work", + })) + })?; + let candidates = generate_puzzle_image_candidates( + state, + owner_user_id, + &session.session_id, + &draft.level_name, + &draft.summary, + 2, + ) + .await + .map_err(|message| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": message, + })) + })?; + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(to_puzzle_generated_image_candidate) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let updated_session = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id, + owner_user_id: owner_user_id.to_string(), + candidates_json, + saved_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error)?; + let candidate = updated_session + .draft + .as_ref() + .and_then(|draft| draft.candidates.iter().find(|candidate| !candidate.image_src.is_empty())) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": "现场生成后没有可用候选图", + })) + })?; + Ok(build_next_run_from_candidate(run, &updated_session, candidate)) +} + +async fn resolve_gallery_next_puzzle_work( + state: &AppState, + run: &PuzzleRunRecord, +) -> Result, AppError> { + let items = state + .spacetime_client() + .list_puzzle_gallery() + .await + .map_err(map_puzzle_client_error)?; + Ok(items.into_iter().find(|item| { + item.publication_status == "published" + && item.cover_image_src.as_ref().is_some_and(|value| !value.is_empty()) + && !run.played_profile_ids.contains(&item.profile_id) + })) +} + +fn pick_unused_puzzle_candidate<'a>( + candidates: &'a [PuzzleGeneratedImageCandidateRecord], + played_profile_ids: &[String], +) -> Option<&'a PuzzleGeneratedImageCandidateRecord> { + candidates.iter().find(|candidate| { + !candidate.image_src.is_empty() + && !played_profile_ids + .iter() + .any(|profile_id| profile_id.contains(&candidate.candidate_id)) + }) +} + +fn build_next_run_from_puzzle_work( + run: PuzzleRunRecord, + item: PuzzleWorkProfileRecord, +) -> PuzzleRunRecord { + build_next_run_from_parts( + run, + item.profile_id, + item.level_name, + item.author_display_name, + item.theme_tags, + item.cover_image_src, + ) +} + +fn build_next_run_from_candidate( + run: PuzzleRunRecord, + session: &PuzzleAgentSessionRecord, + candidate: &PuzzleGeneratedImageCandidateRecord, +) -> PuzzleRunRecord { + let draft = session.draft.as_ref(); + let level_index = run.current_level_index + 1; + build_next_run_from_parts( + run, + format!( + "{}-{}-level-{}", + session.session_id, candidate.candidate_id, level_index + ), + draft + .map(|draft| format!("{} · 候选 {}", draft.level_name, level_index)) + .unwrap_or_else(|| format!("候选拼图 {level_index}")), + "当前草稿".to_string(), + draft.map(|draft| draft.theme_tags.clone()).unwrap_or_default(), + Some(candidate.image_src.clone()), + ) +} + +fn build_next_run_from_parts( + run: PuzzleRunRecord, + profile_id: String, + level_name: String, + author_display_name: String, + theme_tags: Vec, + cover_image_src: Option, +) -> PuzzleRunRecord { + let next_level_index = run.current_level_index + 1; + let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 }; + let mut played_profile_ids = run.played_profile_ids.clone(); + if !played_profile_ids.contains(&profile_id) { + played_profile_ids.push(profile_id.clone()); + } + PuzzleRunRecord { + run_id: run.run_id.clone(), + entry_profile_id: run.entry_profile_id, + cleared_level_count: run.cleared_level_count, + current_level_index: next_level_index, + current_grid_size: grid_size, + played_profile_ids, + previous_level_tags: theme_tags.clone(), + current_level: Some(PuzzleRuntimeLevelRecord { + run_id: run.run_id, + level_index: next_level_index, + grid_size, + profile_id, + level_name, + author_display_name, + theme_tags, + cover_image_src, + board: build_local_puzzle_board(grid_size), + status: "playing".to_string(), + }), + recommended_next_profile_id: None, + } +} + +fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord { + let total = grid_size * grid_size; + let mut positions = (0..total) + .map(|index| PuzzleCellPositionRecord { + row: index / grid_size, + col: index % grid_size, + }) + .collect::>(); + if !positions.is_empty() { + let first = positions.remove(0); + positions.push(first); + } + let pieces = (0..total) + .map(|index| { + let current = positions + .get(index as usize) + .cloned() + .unwrap_or(PuzzleCellPositionRecord { + row: index / grid_size, + col: index % grid_size, + }); + PuzzlePieceStateRecord { + piece_id: format!("piece-{index}"), + correct_row: index / grid_size, + correct_col: index % grid_size, + current_row: current.row, + current_col: current.col, + merged_group_id: None, + } + }) + .collect(); + PuzzleBoardRecord { + rows: grid_size, + cols: grid_size, + pieces, + merged_groups: Vec::new(), + selected_piece_id: None, + all_tiles_resolved: false, + } +} + struct PuzzleDashScopeSettings { base_url: String, api_key: String, diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 62caa0b1..1c3d6452 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -6,6 +6,14 @@ pub struct StartPuzzleRunRequest { pub profile_id: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdvanceLocalPuzzleNextLevelRequest { + pub run: PuzzleRunSnapshotResponse, + #[serde(default)] + pub source_session_id: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SwapPuzzlePiecesRequest { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 51b2f5ac..7197eb0a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -23,7 +23,6 @@ import type { PuzzleAgentActionRequest, PuzzleAgentOperationRecord, } from '../../../packages/shared/src/contracts/puzzleAgentActions'; -import type { PuzzleGeneratedImageCandidate } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, @@ -68,13 +67,9 @@ import { getPuzzleAgentSession, streamPuzzleAgentMessage, } from '../../services/puzzle-agent'; +import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery'; +import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime'; import { - getPuzzleGalleryDetail, - listPuzzleGallery, -} from '../../services/puzzle-gallery'; -import { - advanceLocalPuzzleLevel, - advanceLocalPuzzleLevelWithWork, dragLocalPuzzlePiece, startLocalPuzzleRun, swapLocalPuzzlePieces, @@ -114,53 +109,6 @@ type AgentResultPublishGateView = { publishReady: boolean; }; -function buildPuzzleCandidateWorkSummary( - candidate: PuzzleGeneratedImageCandidate, - session: PuzzleAgentSessionSnapshot, - levelIndex: number, -): PuzzleWorkSummary { - const draft = session.draft; - const nowIso = new Date().toISOString(); - return { - workId: `${session.sessionId}-${candidate.candidateId}-level-${levelIndex}-runtime-work`, - profileId: `${session.sessionId}-${candidate.candidateId}-level-${levelIndex}-runtime-profile`, - ownerUserId: 'local-runtime', - sourceSessionId: session.sessionId, - authorDisplayName: '当前草稿', - levelName: draft?.levelName - ? `${draft.levelName} · 候选 ${levelIndex}` - : `候选拼图 ${levelIndex}`, - summary: draft?.summary ?? candidate.prompt, - themeTags: draft?.themeTags ?? [], - coverImageSrc: candidate.imageSrc, - coverAssetId: candidate.assetId, - publicationStatus: 'published', - updatedAt: nowIso, - publishedAt: nowIso, - playCount: 0, - publishReady: true, - }; -} - -function pickPuzzleCandidateForLevel( - candidates: PuzzleGeneratedImageCandidate[], - playedProfileIds: string[], -) { - return candidates.find( - (candidate) => - candidate.imageSrc && - !playedProfileIds.some((profileId) => - profileId.includes(candidate.candidateId), - ), - ); -} - -function pickFreshGeneratedPuzzleCandidate( - candidates: PuzzleGeneratedImageCandidate[], -) { - return candidates.find((candidate) => candidate.imageSrc); -} - type AgentResultBlockerView = { code?: string; message: string; @@ -1226,104 +1174,16 @@ export function PlatformEntryFlowShellImpl({ } setIsPuzzleBusy(true); + setIsPuzzleNextLevelGenerating(true); setPuzzleError(null); try { - const galleryResponse = await listPuzzleGallery(); - setPuzzleWorks(galleryResponse.items); - const galleryNext = galleryResponse.items.find( - (item) => - item.publicationStatus === 'published' && - item.coverImageSrc && - !puzzleRun.playedProfileIds.includes(item.profileId), - ); - if (galleryNext) { - const { item } = await getPuzzleGalleryDetail(galleryNext.profileId); - setSelectedPuzzleDetail(item); - setPuzzleRun(advanceLocalPuzzleLevelWithWork(puzzleRun, item)); - return; - } - - const existingCandidate = pickPuzzleCandidateForLevel( - puzzleSession?.draft?.candidates ?? [], - puzzleRun.playedProfileIds, - ); - if (existingCandidate && puzzleSession) { - setPuzzleRun( - advanceLocalPuzzleLevelWithWork( - puzzleRun, - buildPuzzleCandidateWorkSummary( - existingCandidate, - puzzleSession, - puzzleRun.currentLevelIndex + 1, - ), - ), - ); - return; - } - - if (!puzzleSession?.draft) { - const sourceSessionId = selectedPuzzleDetail?.sourceSessionId?.trim(); - if (sourceSessionId) { - const { session } = await getPuzzleAgentSession(sourceSessionId); - setPuzzleSession(session); - if (session.draft) { - setIsPuzzleNextLevelGenerating(true); - const response = await executePuzzleAgentAction(session.sessionId, { - action: 'generate_puzzle_images', - candidateCount: 2, - }); - setPuzzleOperation(response.operation); - setPuzzleSession(response.session); - const sourceSessionCandidate = pickPuzzleCandidateForLevel( - response.session.draft?.candidates ?? [], - puzzleRun.playedProfileIds, - ); - if (sourceSessionCandidate) { - setPuzzleRun( - advanceLocalPuzzleLevelWithWork( - puzzleRun, - buildPuzzleCandidateWorkSummary( - sourceSessionCandidate, - response.session, - puzzleRun.currentLevelIndex + 1, - ), - ), - ); - return; - } - } - } - throw new Error('当前拼图缺少可用于生成下一关的草稿会话。'); - } - - setIsPuzzleNextLevelGenerating(true); - const response = await executePuzzleAgentAction(puzzleSession.sessionId, { - action: 'generate_puzzle_images', - candidateCount: 2, + const { run } = await advanceLocalPuzzleNextLevel({ + run: puzzleRun, + sourceSessionId: + selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null, }); - setPuzzleOperation(response.operation); - setPuzzleSession(response.session); - - const generatedCandidate = pickPuzzleCandidateForLevel( - response.session.draft?.candidates ?? [], - puzzleRun.playedProfileIds, - ); - if (generatedCandidate) { - setPuzzleRun( - advanceLocalPuzzleLevelWithWork( - puzzleRun, - buildPuzzleCandidateWorkSummary( - generatedCandidate, - response.session, - puzzleRun.currentLevelIndex + 1, - ), - ), - ); - return; - } - - setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun)); + setPuzzleRun(run); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); } finally { diff --git a/src/services/puzzle-runtime/index.ts b/src/services/puzzle-runtime/index.ts index 18d6f801..97f3fb58 100644 --- a/src/services/puzzle-runtime/index.ts +++ b/src/services/puzzle-runtime/index.ts @@ -1,4 +1,5 @@ export { + advanceLocalPuzzleNextLevel, advancePuzzleNextLevel, dragPuzzlePieceOrGroup, getPuzzleRun, diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index 527a207f..f699c321 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -112,13 +112,13 @@ function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) { return `${entryProfileId}::local-level-${levelIndex}`; } -// 第一版单机玩法没有后端推荐池,本地沿用当前作品图片并生成可推进的临时关卡名。 +// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。 function buildLocalLevelName(previousLevelName: string, levelIndex: number) { return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`; } -// 本地运行态只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。 -function buildNextLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { +// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。 +function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { const currentLevel = run.currentLevel; if (!currentLevel || currentLevel.status !== 'cleared') { return run; @@ -240,5 +240,5 @@ export function dragLocalPuzzlePiece( } export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { - return buildNextLocalLevel(run); + return buildFallbackLocalLevel(run); } diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index e1974be5..ae320a30 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -1,4 +1,5 @@ import type { + AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleRunResponse, StartPuzzleRunRequest, @@ -111,7 +112,28 @@ export async function advancePuzzleNextLevel(runId: string) { ); } +/** + * 单机运行态进入下一关,图片来源选择全部由后端裁决。 + */ +export async function advanceLocalPuzzleNextLevel( + payload: AdvanceLocalPuzzleNextLevelRequest, +) { + return requestJson( + `${PUZZLE_RUNTIME_API_BASE}/local-next-level`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + '进入下一关失败', + { + retry: PUZZLE_RUNTIME_WRITE_RETRY, + }, + ); +} + export const puzzleRuntimeClient = { + advanceLocalNextLevel: advanceLocalPuzzleNextLevel, advanceNextLevel: advancePuzzleNextLevel, drag: dragPuzzlePieceOrGroup, getRun: getPuzzleRun,