fix: restore puzzle next level flow
This commit is contained in:
@@ -15,7 +15,8 @@
|
|||||||
2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。
|
2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。
|
||||||
3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。
|
3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。
|
||||||
4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。
|
4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。
|
||||||
5. 后端仍然负责:
|
5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。
|
||||||
|
6. 后端仍然负责:
|
||||||
- Agent 会话
|
- Agent 会话
|
||||||
- 结果页草稿编译
|
- 结果页草稿编译
|
||||||
- 正式候选图生成
|
- 正式候选图生成
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
|
|
||||||
不能继续写到仓库本地 `public/generated-puzzle-covers/*`。
|
不能继续写到仓库本地 `public/generated-puzzle-covers/*`。
|
||||||
|
|
||||||
|
这些路径只是前后端 DTO 里的兼容标识,不是浏览器可以直接裸读的公开资源地址。实际图片对象存放在私有 OSS 中,前端渲染前必须先通过 `/api/assets/read-url?legacyPublicPath=...` 换取签名读 URL;签名 URL 未返回或换签失败时,图片组件不能把 `/generated-puzzle-assets/*` 直接写入 `<img src>`,避免浏览器发起无签名、无鉴权请求。
|
||||||
|
|
||||||
### 4.2 运行态边界
|
### 4.2 运行态边界
|
||||||
|
|
||||||
第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。
|
第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。
|
||||||
@@ -55,7 +58,9 @@
|
|||||||
|
|
||||||
1. 进入玩法时从作品详情构造本地 `run`
|
1. 进入玩法时从作品详情构造本地 `run`
|
||||||
2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run`
|
2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run`
|
||||||
3. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链
|
3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮
|
||||||
|
4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4`
|
||||||
|
5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链
|
||||||
|
|
||||||
## 5. 当前实现判断标准
|
## 5. 当前实现判断标准
|
||||||
|
|
||||||
@@ -65,4 +70,5 @@
|
|||||||
2. 返回路径切到 `/generated-puzzle-assets/*`。
|
2. 返回路径切到 `/generated-puzzle-assets/*`。
|
||||||
3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。
|
3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。
|
||||||
4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。
|
4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。
|
||||||
5. 关闭玩法后不保留当前 run 进度。
|
5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。
|
||||||
|
6. 关闭玩法后不保留当前 run 进度。
|
||||||
|
|||||||
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
@@ -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<typeof startLocalPuzzleRun>) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,17 +89,66 @@ function applyNextBoard(
|
|||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||||
|
const nextClearedLevelCount =
|
||||||
|
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||||
|
? run.clearedLevelCount + 1
|
||||||
|
: run.clearedLevelCount;
|
||||||
return {
|
return {
|
||||||
...run,
|
...run,
|
||||||
clearedLevelCount:
|
clearedLevelCount: nextClearedLevelCount,
|
||||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
|
||||||
? run.clearedLevelCount + 1
|
|
||||||
: run.clearedLevelCount,
|
|
||||||
currentLevel: {
|
currentLevel: {
|
||||||
...run.currentLevel,
|
...run.currentLevel,
|
||||||
board: nextBoard,
|
board: nextBoard,
|
||||||
status,
|
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 {
|
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||||
return run;
|
return buildNextLocalLevel(run);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user