304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
|
|
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
|
import {
|
|
advanceLocalPuzzleLevel,
|
|
dragLocalPuzzlePiece,
|
|
startLocalPuzzleRun,
|
|
swapLocalPuzzlePieces,
|
|
} 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 hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
|
return pieces.some((piece) =>
|
|
pieces.some((candidate) => {
|
|
if (piece.pieceId === candidate.pieceId) {
|
|
return false;
|
|
}
|
|
const currentRowDelta = candidate.currentRow - piece.currentRow;
|
|
const currentColDelta = candidate.currentCol - piece.currentCol;
|
|
const correctRowDelta = candidate.correctRow - piece.correctRow;
|
|
const correctColDelta = candidate.correctCol - piece.correctCol;
|
|
return (
|
|
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
|
currentRowDelta === correctRowDelta &&
|
|
currentColDelta === correctColDelta
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
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('每次启动都会生成不同的初始打乱样式', async () => {
|
|
const firstRun = startLocalPuzzleRun(baseWork);
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
const secondRun = startLocalPuzzleRun(baseWork);
|
|
const firstPositions = firstRun.currentLevel?.board.pieces.map((piece) => [
|
|
piece.currentRow,
|
|
piece.currentCol,
|
|
]);
|
|
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
|
|
piece.currentRow,
|
|
piece.currentCol,
|
|
]);
|
|
|
|
expect(firstPositions).not.toEqual(secondPositions);
|
|
});
|
|
|
|
test('初始棋盘没有任何自动合并块', () => {
|
|
for (let index = 0; index < 12; index += 1) {
|
|
const run = startLocalPuzzleRun(baseWork);
|
|
const board = run.currentLevel?.board;
|
|
|
|
expect(board?.mergedGroups).toEqual([]);
|
|
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('交换后正确相邻的块会自动合并', () => {
|
|
const run = startLocalPuzzleRun(baseWork);
|
|
const board = run.currentLevel?.board;
|
|
expect(board).toBeTruthy();
|
|
if (!run.currentLevel || !board) {
|
|
return;
|
|
}
|
|
|
|
const nextRun = {
|
|
...run,
|
|
currentLevel: {
|
|
...run.currentLevel,
|
|
board: {
|
|
...board,
|
|
pieces: board.pieces.map((piece) => {
|
|
const layout: Record<string, [number, number]> = {
|
|
'piece-0': [1, 1],
|
|
'piece-1': [0, 1],
|
|
'piece-2': [2, 2],
|
|
'piece-3': [0, 2],
|
|
'piece-4': [1, 0],
|
|
'piece-5': [2, 0],
|
|
'piece-6': [0, 0],
|
|
'piece-7': [1, 2],
|
|
'piece-8': [2, 1],
|
|
};
|
|
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
|
|
return {
|
|
...piece,
|
|
currentRow: current[0],
|
|
currentCol: current[1],
|
|
mergedGroupId: null,
|
|
};
|
|
}),
|
|
mergedGroups: [],
|
|
allTilesResolved: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const swapped = swapLocalPuzzlePieces(nextRun, {
|
|
firstPieceId: 'piece-0',
|
|
secondPieceId: 'piece-6',
|
|
});
|
|
|
|
expect(
|
|
swapped.currentLevel?.board.mergedGroups.some(
|
|
(group) =>
|
|
group.pieceIds.includes('piece-0') &&
|
|
group.pieceIds.includes('piece-1'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('全部拼块汇成一个大合并块后判定通关', () => {
|
|
const run = startLocalPuzzleRun(baseWork);
|
|
const board = run.currentLevel?.board;
|
|
expect(board).toBeTruthy();
|
|
if (!run.currentLevel || !board) {
|
|
return;
|
|
}
|
|
|
|
const solvedByOneGroup = {
|
|
...run,
|
|
currentLevel: {
|
|
...run.currentLevel,
|
|
board: {
|
|
...board,
|
|
pieces: board.pieces.map((piece, index) => ({
|
|
...piece,
|
|
currentRow: Math.floor(index / board.cols),
|
|
currentCol: (index + 1) % board.cols,
|
|
mergedGroupId: 'group-full',
|
|
})),
|
|
mergedGroups: [
|
|
{
|
|
groupId: 'group-full',
|
|
pieceIds: board.pieces.map((piece) => piece.pieceId),
|
|
occupiedCells: board.pieces.map((_, index) => ({
|
|
row: Math.floor(index / board.cols),
|
|
col: (index + 1) % board.cols,
|
|
})),
|
|
},
|
|
],
|
|
allTilesResolved: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(solvedByOneGroup.currentLevel.board.allTilesResolved).toBe(true);
|
|
});
|
|
|
|
test('大合并块覆盖多个小块时会与被覆盖块逐一交换,不会出现小块消失', () => {
|
|
const run = startLocalPuzzleRun(baseWork);
|
|
const board = run.currentLevel?.board;
|
|
expect(board).toBeTruthy();
|
|
if (!run.currentLevel || !board) {
|
|
return;
|
|
}
|
|
|
|
const preparedRun = {
|
|
...run,
|
|
currentLevel: {
|
|
...run.currentLevel,
|
|
board: {
|
|
...board,
|
|
pieces: board.pieces.map((piece) => {
|
|
const layout: Record<string, [number, number, string | null]> = {
|
|
'piece-0': [0, 0, 'group-1'],
|
|
'piece-1': [0, 1, 'group-1'],
|
|
'piece-2': [0, 2, null],
|
|
'piece-3': [1, 0, 'group-1'],
|
|
'piece-4': [1, 1, 'group-1'],
|
|
'piece-5': [1, 2, null],
|
|
'piece-6': [2, 0, null],
|
|
'piece-7': [2, 1, null],
|
|
'piece-8': [2, 2, null],
|
|
};
|
|
const current = layout[piece.pieceId] ?? [
|
|
piece.currentRow,
|
|
piece.currentCol,
|
|
piece.mergedGroupId,
|
|
];
|
|
return {
|
|
...piece,
|
|
currentRow: current[0],
|
|
currentCol: current[1],
|
|
mergedGroupId: current[2],
|
|
};
|
|
}),
|
|
mergedGroups: [
|
|
{
|
|
groupId: 'group-1',
|
|
pieceIds: ['piece-0', 'piece-1', 'piece-3', 'piece-4'],
|
|
occupiedCells: [
|
|
{ row: 0, col: 0 },
|
|
{ row: 0, col: 1 },
|
|
{ row: 1, col: 0 },
|
|
{ row: 1, col: 1 },
|
|
],
|
|
},
|
|
],
|
|
allTilesResolved: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const dragged = dragLocalPuzzlePiece(preparedRun, {
|
|
pieceId: 'piece-0',
|
|
targetRow: 1,
|
|
targetCol: 1,
|
|
});
|
|
|
|
const nextBoard = dragged.currentLevel?.board;
|
|
expect(nextBoard).toBeTruthy();
|
|
if (!nextBoard) {
|
|
return;
|
|
}
|
|
|
|
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
|
|
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
|
|
expect(
|
|
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
|
|
).toMatchObject({ currentRow: 0, currentCol: 0 });
|
|
expect(
|
|
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-7'),
|
|
).toMatchObject({ currentRow: 0, currentCol: 1 });
|
|
expect(
|
|
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-8'),
|
|
).toMatchObject({ currentRow: 1, currentCol: 0 });
|
|
expect(
|
|
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-0'),
|
|
).toMatchObject({ currentRow: 1, currentCol: 1 });
|
|
expect(
|
|
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-4'),
|
|
).toMatchObject({ currentRow: 2, currentCol: 2 });
|
|
});
|
|
|
|
test('通关后提供下一关入口并能推进到新棋盘', () => {
|
|
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
|
|
|
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
|
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
|
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
|
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
|
expect(
|
|
clearedRun.currentLevel?.leaderboardEntries.some(
|
|
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
|
),
|
|
).toBe(true);
|
|
|
|
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.currentLevel?.elapsedMs).toBeNull();
|
|
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
|
expect(nextRun.recommendedNextProfileId).toBeNull();
|
|
});
|
|
});
|