import type { DragPuzzlePieceRequest, PuzzleBoardSnapshot, PuzzleCellPosition, PuzzleGridSize, PuzzleMergedGroupState, PuzzlePieceState, PuzzleRunSnapshot, SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize { return clearedLevelCount >= 3 ? 4 : 3; } const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64; function buildShuffleSeed(...parts: Array) { let hash = 0x811c9dc5; for (const part of parts.join('|')) { hash ^= part.charCodeAt(0); hash = Math.imul(hash, 16777619) >>> 0; } return hash || 1; } function shufflePositions( positions: PuzzleCellPosition[], seed: number, ): PuzzleCellPosition[] { const shuffled = positions.map((position) => ({ ...position })); let state = seed >>> 0; for (let index = shuffled.length - 1; index > 0; index -= 1) { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; const swapIndex = state % (index + 1); const currentPosition = shuffled[index]; const swapPosition = shuffled[swapIndex]; if (!currentPosition || !swapPosition) { continue; } shuffled[index] = swapPosition; shuffled[swapIndex] = currentPosition; } return shuffled; } function ensureBoardIsNotSolved( positions: PuzzleCellPosition[], gridSize: PuzzleGridSize, ) { const solved = positions.every( (position, index) => position.row === Math.floor(index / gridSize) && position.col === index % gridSize, ); if (solved && positions.length > 1) { const first = positions.shift(); if (first) { positions.push(first); } } } function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) { const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({ row: Math.floor(index / gridSize), col: index % gridSize, })); for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) { const shuffled = shufflePositions( positions, (seed + Math.imul(attempt, 2654435761)) >>> 0, ); ensureBoardIsNotSolved(shuffled, gridSize); const pieces = buildPiecesFromPositions(gridSize, shuffled); if (!hasAnyOriginalNeighborPair(pieces)) { return shuffled; } } return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions; } function boardCellKey(row: number, col: number) { return `${row}:${col}`; } function clampElapsedMs(value: number) { return Math.max(1_000, Math.round(value)); } function neighborCells(row: number, col: number): PuzzleCellPosition[] { return [ row > 0 ? { row: row - 1, col } : null, { row: row + 1, col }, col > 0 ? { row, col: col - 1 } : null, { row, col: col + 1 }, ].filter((cell): cell is PuzzleCellPosition => Boolean(cell)); } function areCorrectNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) { const currentRowDelta = right.currentRow - left.currentRow; const currentColDelta = right.currentCol - left.currentCol; const correctRowDelta = right.correctRow - left.correctRow; const correctColDelta = right.correctCol - left.correctCol; return ( Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 && currentRowDelta === correctRowDelta && currentColDelta === correctColDelta ); } function buildPiecesFromPositions( gridSize: PuzzleGridSize, positions: PuzzleCellPosition[], ): PuzzlePieceState[] { return positions.map((current, index) => ({ pieceId: `piece-${index}`, correctRow: Math.floor(index / gridSize), correctCol: index % gridSize, currentRow: current.row, currentCol: current.col, mergedGroupId: null, })); } function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) { const piecesByCell = new Map( pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]), ); return pieces.some((piece) => neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => { const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col)); return Boolean(neighborPiece && areCorrectNeighbors(piece, neighborPiece)); }), ); } function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) { return ( Math.abs(right.correctRow - left.correctRow) + Math.abs(right.correctCol - left.correctCol) === 1 ); } function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) { const piecesByCell = new Map( pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]), ); return pieces.some((piece) => neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => { const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col)); return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece)); }), ); } function seededOrderKey(seed: number, value: number) { let state = (seed ^ Math.imul(value, 2654435761)) >>> 0; state ^= state >>> 16; state = Math.imul(state, 2246822507) >>> 0; state ^= state >>> 13; state = Math.imul(state, 3266489909) >>> 0; return (state ^ (state >>> 16)) >>> 0; } function buildOriginalNeighborFreePositions( gridSize: PuzzleGridSize, seed: number, ) { const total = gridSize * gridSize; const pieceOrder = Array.from({ length: total }, (_, index) => index).sort( (left, right) => seededOrderKey(seed ^ 0xa0761d64, left) - seededOrderKey(seed ^ 0xa0761d64, right), ); const cellOrder = Array.from({ length: total }, (_, index) => ({ row: Math.floor(index / gridSize), col: index % gridSize, })).sort( (left, right) => seededOrderKey(seed ^ 0xe7037ed1, left.row * 16 + left.col) - seededOrderKey(seed ^ 0xe7037ed1, right.row * 16 + right.col), ); const placements: Array = Array.from( { length: total }, () => null, ); const usedCells = new Set(); const placePiece = (depth: number): boolean => { const pieceIndex = pieceOrder[depth]; if (pieceIndex === undefined) { return true; } for (const cell of cellOrder) { const cellKey = boardCellKey(cell.row, cell.col); if (usedCells.has(cellKey)) { continue; } if ( cell.row === Math.floor(pieceIndex / gridSize) && cell.col === pieceIndex % gridSize ) { continue; } if ( violatesOriginalNeighborFreeRule(gridSize, pieceIndex, cell, placements) ) { continue; } placements[pieceIndex] = cell; usedCells.add(cellKey); if (placePiece(depth + 1)) { return true; } usedCells.delete(cellKey); placements[pieceIndex] = null; } return false; }; return placePiece(0) && placements.every(Boolean) ? (placements as PuzzleCellPosition[]) : null; } function violatesOriginalNeighborFreeRule( gridSize: PuzzleGridSize, pieceIndex: number, cell: PuzzleCellPosition, placements: Array, ) { return placements.some((placedCell, placedIndex) => { if (!placedCell) { return false; } const originalNeighbors = Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) + Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) === 1; const currentNeighbors = Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) === 1; return originalNeighbors && currentNeighbors; }); } function resolveMergedGroups( pieces: PuzzlePieceState[], ): PuzzleMergedGroupState[] { const piecesByCell = new Map( pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]), ); const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece])); const visited = new Set(); const groups: PuzzleMergedGroupState[] = []; for (const piece of pieces) { if (visited.has(piece.pieceId)) { continue; } const queue = [piece.pieceId]; const pieceIds: string[] = []; while (queue.length) { const currentPieceId = queue.shift(); if (!currentPieceId || visited.has(currentPieceId)) { continue; } visited.add(currentPieceId); const currentPiece = piecesById.get(currentPieceId); if (!currentPiece) { continue; } pieceIds.push(currentPieceId); for (const neighbor of neighborCells( currentPiece.currentRow, currentPiece.currentCol, )) { const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col)); if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) { queue.push(neighborPiece.pieceId); } } } if (pieceIds.length <= 1) { continue; } groups.push({ groupId: `group-${groups.length + 1}`, pieceIds, occupiedCells: pieceIds .map((pieceId) => piecesById.get(pieceId)) .filter((value): value is PuzzlePieceState => Boolean(value)) .map((value) => ({ row: value.currentRow, col: value.currentCol })), }); } return groups; } function rebuildBoardSnapshot( gridSize: PuzzleGridSize, pieces: PuzzlePieceState[], ): PuzzleBoardSnapshot { const mergedGroups = resolveMergedGroups(pieces).map((group, index) => ({ ...group, groupId: `group-${index + 1}`, })); const groupByPiece = new Map( mergedGroups.flatMap((group) => group.pieceIds.map((pieceId) => [pieceId, group.groupId] as const), ), ); const nextPieces = pieces.map((piece) => ({ ...piece, mergedGroupId: groupByPiece.get(piece.pieceId) ?? null, })); const allPiecesInCorrectCells = nextPieces.every( (piece) => piece.currentRow === piece.correctRow && piece.currentCol === piece.correctCol, ); const allPiecesMergedIntoOneGroup = mergedGroups.some( (group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1, ); const allTilesResolved = allPiecesInCorrectCells || allPiecesMergedIntoOneGroup; return { rows: gridSize, cols: gridSize, pieces: nextPieces, mergedGroups, selectedPieceId: null, allTilesResolved, }; } function buildInitialBoard( gridSize: PuzzleGridSize, runId: string, profileId: string, levelIndex: number, ): PuzzleBoardSnapshot { const shuffledPositions = buildInitialPositions( gridSize, buildShuffleSeed(runId, profileId, levelIndex, Date.now()), ); const pieces = buildPiecesFromPositions(gridSize, shuffledPositions); return rebuildBoardSnapshot(gridSize, pieces); } function applyNextBoard( run: PuzzleRunSnapshot, nextBoard: PuzzleBoardSnapshot, ): PuzzleRunSnapshot { if (!run.currentLevel) { return run; } const status = nextBoard.allTilesResolved ? 'cleared' : 'playing'; const nextClearedLevelCount = status === 'cleared' && run.currentLevel.status !== 'cleared' ? run.clearedLevelCount + 1 : run.clearedLevelCount; const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared'; const nowMs = Date.now(); const clearedAtMs = justCleared ? nowMs : (run.currentLevel.clearedAtMs ?? null); const elapsedMs = justCleared ? clampElapsedMs(nowMs - run.currentLevel.startedAtMs) : (run.currentLevel.elapsedMs ?? null); return { ...run, clearedLevelCount: nextClearedLevelCount, currentLevel: { ...run.currentLevel, board: nextBoard, status, clearedAtMs, elapsedMs, leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries, }, leaderboardEntries: justCleared ? [] : run.leaderboardEntries, 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 buildFallbackLocalLevel(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); const startedAtMs = Date.now(); 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, run.runId, nextProfileId, nextLevelIndex), status: 'playing', startedAtMs, clearedAtMs: null, elapsedMs: null, leaderboardEntries: [], }, recommendedNextProfileId: null, leaderboardEntries: [], }; } export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot { const gridSize = resolvePuzzleGridSize(0); const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`; const startedAtMs = Date.now(); return { runId, entryProfileId: item.profileId, clearedLevelCount: 0, currentLevelIndex: 1, currentGridSize: gridSize, playedProfileIds: [item.profileId], previousLevelTags: item.themeTags, currentLevel: { runId, levelIndex: 1, gridSize, profileId: item.profileId, levelName: item.levelName, authorDisplayName: item.authorDisplayName, themeTags: item.themeTags, coverImageSrc: item.coverImageSrc, board: buildInitialBoard(gridSize, runId, item.profileId, 1), status: 'playing', startedAtMs, clearedAtMs: null, elapsedMs: null, leaderboardEntries: [], }, recommendedNextProfileId: null, leaderboardEntries: [], }; } export function swapLocalPuzzlePieces( run: PuzzleRunSnapshot, payload: SwapPuzzlePiecesRequest, ): PuzzleRunSnapshot { const currentLevel = run.currentLevel; if (!currentLevel || currentLevel.status === 'cleared') { return run; } const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece })); const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId); const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId); if (!first || !second) { return run; } const firstPosition = { row: first.currentRow, col: first.currentCol }; first.currentRow = second.currentRow; first.currentCol = second.currentCol; second.currentRow = firstPosition.row; second.currentCol = firstPosition.col; return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces)); } function dragSinglePiece( pieces: PuzzlePieceState[], moving: PuzzlePieceState, targetRow: number, targetCol: number, ) { const occupying = pieces.find( (piece) => piece.pieceId !== moving.pieceId && piece.currentRow === targetRow && piece.currentCol === targetCol, ); if (occupying?.mergedGroupId) { for (const piece of pieces) { if (piece.mergedGroupId === occupying.mergedGroupId) { piece.mergedGroupId = null; } } } const source = { row: moving.currentRow, col: moving.currentCol }; moving.currentRow = targetRow; moving.currentCol = targetCol; if (occupying) { occupying.currentRow = source.row; occupying.currentCol = source.col; } } function dragGroup( pieces: PuzzlePieceState[], moving: PuzzlePieceState, targetRow: number, targetCol: number, gridSize: PuzzleGridSize, ) { if (!moving.mergedGroupId) { return false; } const groupPieces = pieces.filter( (piece) => piece.mergedGroupId === moving.mergedGroupId, ); const rowOffset = targetRow - moving.currentRow; const colOffset = targetCol - moving.currentCol; const targetPositions = groupPieces.map((piece) => ({ piece, row: piece.currentRow + rowOffset, col: piece.currentCol + colOffset, })); if ( targetPositions.some( (position) => position.row < 0 || position.col < 0 || position.row >= gridSize || position.col >= gridSize, ) ) { return false; } const movingIds = new Set(groupPieces.map((piece) => piece.pieceId)); const targetCellKeys = new Set( targetPositions.map((position) => boardCellKey(position.row, position.col)), ); // 大块整体平移后,所有被覆盖的小块必须一对一交换到真正腾出来的格子里, // 不能重复写回同一个源格,否则会出现多个小块重叠并在渲染上“消失”。 const vacatedPositions = groupPieces .map((piece) => ({ row: piece.currentRow, col: piece.currentCol, })) .filter( (position) => !targetCellKeys.has(boardCellKey(position.row, position.col)), ) .sort((left, right) => left.row - right.row || left.col - right.col); const occupyingPieces = targetPositions .map( (target) => pieces.find( (piece) => !movingIds.has(piece.pieceId) && piece.currentRow === target.row && piece.currentCol === target.col, ) ?? null, ) .filter((piece): piece is PuzzlePieceState => Boolean(piece)) .sort( (left, right) => left.currentRow - right.currentRow || left.currentCol - right.currentCol, ); if (occupyingPieces.length !== vacatedPositions.length) { return false; } for (let index = 0; index < occupyingPieces.length; index += 1) { const occupying = occupyingPieces[index]; const fallback = vacatedPositions[index]; if (!occupying || !fallback) { return false; } occupying.mergedGroupId = null; occupying.currentRow = fallback.row; occupying.currentCol = fallback.col; } for (const target of targetPositions) { target.piece.currentRow = target.row; target.piece.currentCol = target.col; } return true; } export function dragLocalPuzzlePiece( run: PuzzleRunSnapshot, payload: DragPuzzlePieceRequest, ): PuzzleRunSnapshot { const currentLevel = run.currentLevel; if (!currentLevel || currentLevel.status === 'cleared') { return run; } if ( payload.targetRow < 0 || payload.targetCol < 0 || payload.targetRow >= currentLevel.gridSize || payload.targetCol >= currentLevel.gridSize ) { return run; } const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece })); const moving = pieces.find((piece) => piece.pieceId === payload.pieceId); if (!moving) { return run; } if (moving.mergedGroupId) { const moved = dragGroup( pieces, moving, payload.targetRow, payload.targetCol, currentLevel.gridSize, ); if (!moved) { return run; } } else { dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol); } return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces)); } export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { return buildFallbackLocalLevel(run); }