fix: move puzzle next level source selection server side
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。
|
2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。
|
||||||
3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。
|
3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。
|
||||||
4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。
|
4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。
|
||||||
5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。
|
5. 通关后的第一版接续按“广场作品优先”执行:先从拼图广场读取未玩过且有正式图的作品;广场没有可用作品时,再使用当前草稿期间已生成但未消费的候选图;候选图仍不足时,现场调用 `generate_puzzle_images` 生成候选图,并在运行页弹出等待面板。
|
||||||
6. 后端仍然负责:
|
6. 后端仍然负责:
|
||||||
- Agent 会话
|
- Agent 会话
|
||||||
- 结果页草稿编译
|
- 结果页草稿编译
|
||||||
@@ -59,8 +59,11 @@
|
|||||||
1. 进入玩法时从作品详情构造本地 `run`
|
1. 进入玩法时从作品详情构造本地 `run`
|
||||||
2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run`
|
2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run`
|
||||||
3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮
|
3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮
|
||||||
4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4`
|
4. 点击下一关时前端只提交当前 `run` 与可选 `sourceSessionId` 到 Rust `api-server`,不在前端判断图片来源
|
||||||
5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链
|
5. `api-server` 优先用广场作品详情构造下一关;如果广场没有可用作品,则把草稿候选图包装成一次本地关卡来源
|
||||||
|
6. 草稿候选图仍不足时,`api-server` 现场调用真实生图链生成候选图;前端只展示等待弹窗并接收最终 `run`
|
||||||
|
7. 每次进入下一关都会重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4`
|
||||||
|
8. 当前不依赖后端 `start/swap/drag/next-level` 接口保存过程状态
|
||||||
|
|
||||||
## 5. 当前实现判断标准
|
## 5. 当前实现判断标准
|
||||||
|
|
||||||
@@ -70,5 +73,7 @@
|
|||||||
2. 返回路径切到 `/generated-puzzle-assets/*`。
|
2. 返回路径切到 `/generated-puzzle-assets/*`。
|
||||||
3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。
|
3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。
|
||||||
4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。
|
4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。
|
||||||
5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。
|
5. 玩家完成整张图后能看到通关态与“下一关”入口。
|
||||||
6. 关闭玩法后不保留当前 run 进度。
|
6. 广场有可用作品时,下一关内容来自广场作品。
|
||||||
|
7. 广场没有可用作品时,下一关内容来自草稿候选图;候选图不足时现场生成并显示等待面板。
|
||||||
|
8. 关闭玩法后不保留当前 run 进度。
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export interface StartPuzzleRunRequest {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdvanceLocalPuzzleNextLevelRequest {
|
||||||
|
run: PuzzleRunSnapshot;
|
||||||
|
sourceSessionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PuzzleRunResponse {
|
export interface PuzzleRunResponse {
|
||||||
run: PuzzleRunSnapshot;
|
run: PuzzleRunSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,11 +77,12 @@ use crate::{
|
|||||||
password_management::{change_password, reset_password},
|
password_management::{change_password, reset_password},
|
||||||
phone_auth::{phone_login, send_phone_code},
|
phone_auth::{phone_login, send_phone_code},
|
||||||
puzzle::{
|
puzzle::{
|
||||||
advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work,
|
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
|
||||||
drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session,
|
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||||
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
|
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||||
list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message,
|
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||||
submit_puzzle_agent_message, swap_puzzle_pieces,
|
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||||
|
swap_puzzle_pieces,
|
||||||
},
|
},
|
||||||
refresh_session::refresh_session,
|
refresh_session::refresh_session,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
@@ -626,6 +627,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
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(
|
.route(
|
||||||
"/api/runtime/puzzle/runs/{run_id}",
|
"/api/runtime/puzzle/runs/{run_id}",
|
||||||
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
|
get(get_puzzle_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ use shared_contracts::{
|
|||||||
},
|
},
|
||||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||||
puzzle_runtime::{
|
puzzle_runtime::{
|
||||||
DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||||
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
|
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||||||
SwapPuzzlePiecesRequest,
|
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
|
||||||
},
|
},
|
||||||
puzzle_works::{
|
puzzle_works::{
|
||||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
@@ -50,9 +50,10 @@ use spacetime_client::{
|
|||||||
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
|
PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord,
|
||||||
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||||
|
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||||
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
|
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
|
||||||
SpacetimeClientError,
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio::time::sleep;
|
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(
|
fn map_puzzle_agent_session_response(
|
||||||
session: PuzzleAgentSessionRecord,
|
session: PuzzleAgentSessionRecord,
|
||||||
) -> PuzzleAgentSessionSnapshotResponse {
|
) -> 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(
|
fn map_puzzle_runtime_level_response(
|
||||||
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
||||||
) -> PuzzleRuntimeLevelSnapshotResponse {
|
) -> PuzzleRuntimeLevelSnapshotResponse {
|
||||||
@@ -1574,6 +1673,248 @@ async fn generate_puzzle_image_candidates(
|
|||||||
.collect())
|
.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 {
|
struct PuzzleDashScopeSettings {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ pub struct StartPuzzleRunRequest {
|
|||||||
pub profile_id: String,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SwapPuzzlePiecesRequest {
|
pub struct SwapPuzzlePiecesRequest {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import type {
|
|||||||
PuzzleAgentActionRequest,
|
PuzzleAgentActionRequest,
|
||||||
PuzzleAgentOperationRecord,
|
PuzzleAgentOperationRecord,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||||
import type { PuzzleGeneratedImageCandidate } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
|
||||||
import type {
|
import type {
|
||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
@@ -68,13 +67,9 @@ import {
|
|||||||
getPuzzleAgentSession,
|
getPuzzleAgentSession,
|
||||||
streamPuzzleAgentMessage,
|
streamPuzzleAgentMessage,
|
||||||
} from '../../services/puzzle-agent';
|
} from '../../services/puzzle-agent';
|
||||||
|
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
||||||
|
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
|
||||||
import {
|
import {
|
||||||
getPuzzleGalleryDetail,
|
|
||||||
listPuzzleGallery,
|
|
||||||
} from '../../services/puzzle-gallery';
|
|
||||||
import {
|
|
||||||
advanceLocalPuzzleLevel,
|
|
||||||
advanceLocalPuzzleLevelWithWork,
|
|
||||||
dragLocalPuzzlePiece,
|
dragLocalPuzzlePiece,
|
||||||
startLocalPuzzleRun,
|
startLocalPuzzleRun,
|
||||||
swapLocalPuzzlePieces,
|
swapLocalPuzzlePieces,
|
||||||
@@ -114,53 +109,6 @@ type AgentResultPublishGateView = {
|
|||||||
publishReady: boolean;
|
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 = {
|
type AgentResultBlockerView = {
|
||||||
code?: string;
|
code?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1226,104 +1174,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsPuzzleBusy(true);
|
setIsPuzzleBusy(true);
|
||||||
|
setIsPuzzleNextLevelGenerating(true);
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const galleryResponse = await listPuzzleGallery();
|
const { run } = await advanceLocalPuzzleNextLevel({
|
||||||
setPuzzleWorks(galleryResponse.items);
|
run: puzzleRun,
|
||||||
const galleryNext = galleryResponse.items.find(
|
sourceSessionId:
|
||||||
(item) =>
|
selectedPuzzleDetail?.sourceSessionId ?? puzzleSession?.sessionId ?? null,
|
||||||
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);
|
setPuzzleRun(run);
|
||||||
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,
|
|
||||||
});
|
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
advanceLocalPuzzleNextLevel,
|
||||||
advancePuzzleNextLevel,
|
advancePuzzleNextLevel,
|
||||||
dragPuzzlePieceOrGroup,
|
dragPuzzlePieceOrGroup,
|
||||||
getPuzzleRun,
|
getPuzzleRun,
|
||||||
|
|||||||
@@ -112,13 +112,13 @@ function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
|||||||
return `${entryProfileId}::local-level-${levelIndex}`;
|
return `${entryProfileId}::local-level-${levelIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一版单机玩法没有后端推荐池,本地沿用当前作品图片并生成可推进的临时关卡名。
|
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
||||||
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||||
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地运行态只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
||||||
function buildNextLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||||
const currentLevel = run.currentLevel;
|
const currentLevel = run.currentLevel;
|
||||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||||
return run;
|
return run;
|
||||||
@@ -240,5 +240,5 @@ export function dragLocalPuzzlePiece(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||||
return buildNextLocalLevel(run);
|
return buildFallbackLocalLevel(run);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdvanceLocalPuzzleNextLevelRequest,
|
||||||
DragPuzzlePieceRequest,
|
DragPuzzlePieceRequest,
|
||||||
PuzzleRunResponse,
|
PuzzleRunResponse,
|
||||||
StartPuzzleRunRequest,
|
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 = {
|
export const puzzleRuntimeClient = {
|
||||||
|
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
|
||||||
advanceNextLevel: advancePuzzleNextLevel,
|
advanceNextLevel: advancePuzzleNextLevel,
|
||||||
drag: dragPuzzlePieceOrGroup,
|
drag: dragPuzzlePieceOrGroup,
|
||||||
getRun: getPuzzleRun,
|
getRun: getPuzzleRun,
|
||||||
|
|||||||
Reference in New Issue
Block a user