fix: move puzzle next level source selection server side

This commit is contained in:
2026-04-25 15:55:05 +08:00
parent 9cb3c6a27e
commit 4e04679ba4
9 changed files with 418 additions and 168 deletions

View File

@@ -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 进度。

View File

@@ -58,6 +58,11 @@ export interface StartPuzzleRunRequest {
profileId: string;
}
export interface AdvanceLocalPuzzleNextLevelRequest {
run: PuzzleRunSnapshot;
sourceSessionId?: string | null;
}
export interface PuzzleRunResponse {
run: PuzzleRunSnapshot;
}

View File

@@ -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(

View File

@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<AdvanceLocalPuzzleNextLevelRequest>, 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(),
})),
)
})?;
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<PuzzleRunRecord, AppError> {
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::<Vec<_>>(),
)
.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<Option<PuzzleWorkProfileRecord>, 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<String>,
cover_image_src: Option<String>,
) -> 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::<Vec<_>>();
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,

View File

@@ -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<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SwapPuzzlePiecesRequest {

View File

@@ -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 {

View File

@@ -1,4 +1,5 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,

View File

@@ -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);
}

View File

@@ -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<PuzzleRunResponse>(
`${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,