From dbf106c74698416e83afc4bf41d4e7122c8b00a5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:07:16 +0800 Subject: [PATCH] fix: restore puzzle next level flow --- ...E_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md | 12 +++- .../puzzle-runtime/puzzleLocalRuntime.test.ts | 69 +++++++++++++++++++ .../puzzle-runtime/puzzleLocalRuntime.ts | 59 ++++++++++++++-- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/services/puzzle-runtime/puzzleLocalRuntime.test.ts 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 c2a78538..9fb0373d 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,8 @@ 2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。 3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。 4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。 -5. 后端仍然负责: +5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。 +6. 后端仍然负责: - Agent 会话 - 结果页草稿编译 - 正式候选图生成 @@ -47,6 +48,8 @@ 不能继续写到仓库本地 `public/generated-puzzle-covers/*`。 +这些路径只是前后端 DTO 里的兼容标识,不是浏览器可以直接裸读的公开资源地址。实际图片对象存放在私有 OSS 中,前端渲染前必须先通过 `/api/assets/read-url?legacyPublicPath=...` 换取签名读 URL;签名 URL 未返回或换签失败时,图片组件不能把 `/generated-puzzle-assets/*` 直接写入 ``,避免浏览器发起无签名、无鉴权请求。 + ### 4.2 运行态边界 第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。 @@ -55,7 +58,9 @@ 1. 进入玩法时从作品详情构造本地 `run` 2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run` -3. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 +3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮 +4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4` +5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 ## 5. 当前实现判断标准 @@ -65,4 +70,5 @@ 2. 返回路径切到 `/generated-puzzle-assets/*`。 3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。 4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。 -5. 关闭玩法后不保留当前 run 进度。 +5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。 +6. 关闭玩法后不保留当前 run 进度。 diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts new file mode 100644 index 00000000..be9ba9e5 --- /dev/null +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, +} from './puzzleLocalRuntime'; + +const baseWork: PuzzleWorkSummary = { + workId: 'work-1', + profileId: 'profile-1', + ownerUserId: 'user-1', + sourceSessionId: null, + authorDisplayName: '测试作者', + levelName: '测试拼图', + summary: '服务层测试用拼图。', + themeTags: ['测试', '拼图'], + coverImageSrc: '/generated-puzzle-assets/test.png', + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T00:00:00.000Z', + publishedAt: '2026-04-25T00:00:00.000Z', + playCount: 0, + publishReady: true, +}; + +function solveCurrentLevel(run: ReturnType) { + let nextRun = run; + for (let index = 0; index < 12; index += 1) { + const currentLevel = nextRun.currentLevel; + if (!currentLevel || currentLevel.status === 'cleared') { + return nextRun; + } + + const misplacedPiece = currentLevel.board.pieces.find( + (piece) => + piece.currentRow !== piece.correctRow || + piece.currentCol !== piece.correctCol, + ); + if (!misplacedPiece) { + return nextRun; + } + + nextRun = dragLocalPuzzlePiece(nextRun, { + pieceId: misplacedPiece.pieceId, + targetRow: misplacedPiece.correctRow, + targetCol: misplacedPiece.correctCol, + }); + } + return nextRun; +} + +describe('puzzleLocalRuntime', () => { + test('通关后提供下一关入口并能推进到新棋盘', () => { + const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork)); + + expect(clearedRun.currentLevel?.status).toBe('cleared'); + expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2'); + + const nextRun = advanceLocalPuzzleLevel(clearedRun); + + expect(nextRun.currentLevelIndex).toBe(2); + expect(nextRun.currentLevel?.status).toBe('playing'); + expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关'); + expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false); + expect(nextRun.recommendedNextProfileId).toBeNull(); + }); +}); diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index cfa15349..527a207f 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -89,17 +89,66 @@ function applyNextBoard( return run; } const status = nextBoard.allTilesResolved ? 'cleared' : 'playing'; + const nextClearedLevelCount = + status === 'cleared' && run.currentLevel.status !== 'cleared' + ? run.clearedLevelCount + 1 + : run.clearedLevelCount; return { ...run, - clearedLevelCount: - status === 'cleared' && run.currentLevel.status !== 'cleared' - ? run.clearedLevelCount + 1 - : run.clearedLevelCount, + clearedLevelCount: nextClearedLevelCount, currentLevel: { ...run.currentLevel, board: nextBoard, status, }, + recommendedNextProfileId: + status === 'cleared' + ? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1) + : run.recommendedNextProfileId, + }; +} + +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 { + const currentLevel = run.currentLevel; + if (!currentLevel || currentLevel.status !== 'cleared') { + return run; + } + + const nextLevelIndex = run.currentLevelIndex + 1; + const gridSize = resolvePuzzleGridSize(run.clearedLevelCount); + const nextProfileId = + run.recommendedNextProfileId ?? + buildLocalNextProfileId(run.entryProfileId, nextLevelIndex); + + return { + ...run, + currentLevelIndex: nextLevelIndex, + currentGridSize: gridSize, + playedProfileIds: run.playedProfileIds.includes(nextProfileId) + ? run.playedProfileIds + : [...run.playedProfileIds, nextProfileId], + previousLevelTags: currentLevel.themeTags, + currentLevel: { + ...currentLevel, + runId: run.runId, + levelIndex: nextLevelIndex, + gridSize, + profileId: nextProfileId, + levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex), + board: buildInitialBoard(gridSize), + status: 'playing', + }, + recommendedNextProfileId: null, }; } @@ -191,5 +240,5 @@ export function dragLocalPuzzlePiece( } export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { - return run; + return buildNextLocalLevel(run); }