1270 lines
34 KiB
TypeScript
1270 lines
34 KiB
TypeScript
import type {
|
|
DragPuzzlePieceRequest,
|
|
PuzzleBoardSnapshot,
|
|
PuzzleCellPosition,
|
|
PuzzleGridSize,
|
|
PuzzleLeaderboardEntry,
|
|
PuzzleMergedGroupState,
|
|
PuzzlePieceState,
|
|
PuzzleRunSnapshot,
|
|
PuzzleRuntimeLevelSnapshot,
|
|
SwapPuzzlePiecesRequest,
|
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
|
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
|
|
|
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
|
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
|
|
const PUZZLE_EXTEND_TIME_DURATION_MS = 60_000;
|
|
let localPuzzleRunSequence = 0;
|
|
type PuzzleLevelConfig = {
|
|
gridSize: PuzzleGridSize;
|
|
timeLimitMs: number;
|
|
};
|
|
|
|
// 中文注释:本地兜底必须和后端按同一关卡序号解析切割规格与倒计时。
|
|
function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
|
|
const normalizedLevelIndex = Math.max(1, Math.floor(levelIndex || 1));
|
|
switch (normalizedLevelIndex) {
|
|
case 1:
|
|
return { gridSize: 3, timeLimitMs: 300_000 };
|
|
case 2:
|
|
return { gridSize: 4, timeLimitMs: 300_000 };
|
|
case 3:
|
|
return { gridSize: 5, timeLimitMs: 300_000 };
|
|
case 4:
|
|
return { gridSize: 5, timeLimitMs: 210_000 };
|
|
default: {
|
|
const loopIndex = ((Math.max(5, normalizedLevelIndex) - 5) % 6) + 5;
|
|
switch (loopIndex) {
|
|
case 5:
|
|
return { gridSize: 5, timeLimitMs: 210_000 };
|
|
case 6:
|
|
return { gridSize: 6, timeLimitMs: 240_000 };
|
|
case 7:
|
|
return { gridSize: 5, timeLimitMs: 210_000 };
|
|
case 8:
|
|
return { gridSize: 7, timeLimitMs: 270_000 };
|
|
case 9:
|
|
return { gridSize: 5, timeLimitMs: 240_000 };
|
|
default:
|
|
return { gridSize: 7, timeLimitMs: 270_000 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
|
|
|
function buildLocalPuzzleRunId(profileId: string) {
|
|
localPuzzleRunSequence = (localPuzzleRunSequence + 1) % 1_000_000;
|
|
return `${LOCAL_PUZZLE_RUN_ID_PREFIX}${profileId}-${Date.now()}-${localPuzzleRunSequence}`;
|
|
}
|
|
|
|
export function resolvePuzzleRestartLevelId(
|
|
run: PuzzleRunSnapshot,
|
|
work: PuzzleWorkSummary | null | undefined,
|
|
): string | null {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel) {
|
|
return null;
|
|
}
|
|
return (
|
|
currentLevel.levelId ??
|
|
work?.levels?.[Math.max(0, currentLevel.levelIndex - 1)]?.levelId ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function buildShuffleSeed(...parts: Array<string | number>) {
|
|
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 (
|
|
buildDeterministicNeighborFreePositions(gridSize, seed) ??
|
|
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 resolvePuzzleLevelTimeLimitMs(levelIndex: number) {
|
|
return resolvePuzzleLevelConfig(levelIndex || 1).timeLimitMs;
|
|
}
|
|
|
|
function resolveActiveFreezeElapsedMs(
|
|
level: PuzzleRuntimeLevelSnapshot,
|
|
nowMs: number,
|
|
) {
|
|
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
|
return 0;
|
|
}
|
|
return Math.max(
|
|
0,
|
|
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
|
|
);
|
|
}
|
|
|
|
function resolveEffectiveElapsedMs(
|
|
level: PuzzleRuntimeLevelSnapshot,
|
|
nowMs: number,
|
|
) {
|
|
const pauseElapsedMs = level.pauseStartedAtMs
|
|
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
|
: 0;
|
|
return Math.max(
|
|
0,
|
|
nowMs -
|
|
level.startedAtMs -
|
|
level.pausedAccumulatedMs -
|
|
pauseElapsedMs -
|
|
level.freezeAccumulatedMs -
|
|
resolveActiveFreezeElapsedMs(level, nowMs),
|
|
);
|
|
}
|
|
|
|
function settleExpiredFreeze(
|
|
level: PuzzleRuntimeLevelSnapshot,
|
|
nowMs: number,
|
|
): PuzzleRuntimeLevelSnapshot {
|
|
if (
|
|
!level.freezeStartedAtMs ||
|
|
!level.freezeUntilMs ||
|
|
nowMs < level.freezeUntilMs
|
|
) {
|
|
return level;
|
|
}
|
|
return {
|
|
...level,
|
|
freezeAccumulatedMs:
|
|
level.freezeAccumulatedMs +
|
|
Math.max(0, level.freezeUntilMs - level.freezeStartedAtMs),
|
|
freezeStartedAtMs: null,
|
|
freezeUntilMs: null,
|
|
};
|
|
}
|
|
|
|
function withResolvedTimer(run: PuzzleRunSnapshot, nowMs = Date.now()) {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'playing') {
|
|
return run;
|
|
}
|
|
const settledLevel = settleExpiredFreeze(currentLevel, nowMs);
|
|
const remainingMs = Math.max(
|
|
0,
|
|
settledLevel.timeLimitMs - resolveEffectiveElapsedMs(settledLevel, nowMs),
|
|
);
|
|
return {
|
|
...run,
|
|
currentLevel: {
|
|
...settledLevel,
|
|
remainingMs,
|
|
status: remainingMs <= 0 ? ('failed' as const) : settledLevel.status,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildLevelTimerFields(levelIndex: number) {
|
|
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(levelIndex);
|
|
return {
|
|
timeLimitMs,
|
|
remainingMs: timeLimitMs,
|
|
pausedAccumulatedMs: 0,
|
|
pauseStartedAtMs: null,
|
|
freezeAccumulatedMs: 0,
|
|
freezeStartedAtMs: null,
|
|
freezeUntilMs: null,
|
|
};
|
|
}
|
|
|
|
function closePauseForLevel(level: PuzzleRuntimeLevelSnapshot, nowMs: number) {
|
|
if (!level.pauseStartedAtMs) {
|
|
return level;
|
|
}
|
|
return {
|
|
...level,
|
|
pausedAccumulatedMs:
|
|
level.pausedAccumulatedMs + Math.max(0, nowMs - level.pauseStartedAtMs),
|
|
pauseStartedAtMs: null,
|
|
};
|
|
}
|
|
|
|
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 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 buildDeterministicNeighborFreePositions(
|
|
gridSize: PuzzleGridSize,
|
|
seed: number,
|
|
) {
|
|
if (gridSize === 3) {
|
|
return buildSeeded3x3NeighborFreePositions(seed);
|
|
}
|
|
if (gridSize === 4 || gridSize === 6) {
|
|
return buildAffineNeighborFreePositions(gridSize, 1, 1, 2, 1, seed);
|
|
}
|
|
if (gridSize === 5 || gridSize === 7) {
|
|
return buildAffineNeighborFreePositions(
|
|
gridSize,
|
|
0,
|
|
1,
|
|
2,
|
|
gridSize - 1,
|
|
seed,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildSeeded3x3NeighborFreePositions(seed: number) {
|
|
const layouts: Array<Array<[number, number]>> = [
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[1, 2],
|
|
[2, 0],
|
|
[0, 2],
|
|
[2, 1],
|
|
[1, 1],
|
|
[2, 2],
|
|
[0, 0],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[1, 2],
|
|
[2, 0],
|
|
[0, 2],
|
|
[2, 1],
|
|
[2, 2],
|
|
[1, 1],
|
|
[0, 0],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[1, 2],
|
|
[2, 0],
|
|
[2, 2],
|
|
[0, 0],
|
|
[1, 1],
|
|
[0, 2],
|
|
[2, 1],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[1, 2],
|
|
[2, 1],
|
|
[0, 2],
|
|
[2, 0],
|
|
[0, 0],
|
|
[2, 2],
|
|
[1, 1],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[1, 2],
|
|
[2, 2],
|
|
[0, 2],
|
|
[2, 1],
|
|
[1, 1],
|
|
[2, 0],
|
|
[0, 0],
|
|
],
|
|
[
|
|
[0, 1],
|
|
[1, 0],
|
|
[2, 1],
|
|
[2, 0],
|
|
[2, 2],
|
|
[0, 2],
|
|
[1, 2],
|
|
[0, 0],
|
|
[1, 1],
|
|
],
|
|
];
|
|
const layout = layouts[Math.abs(seed) % layouts.length] ?? layouts[0];
|
|
return (
|
|
layout?.map(([row, col]) => ({
|
|
row,
|
|
col,
|
|
})) ?? null
|
|
);
|
|
}
|
|
|
|
function buildAffineNeighborFreePositions(
|
|
gridSize: PuzzleGridSize,
|
|
rowFromRow: number,
|
|
rowFromCol: number,
|
|
colFromRow: number,
|
|
colFromCol: number,
|
|
seed: number,
|
|
) {
|
|
const rowOffset = seed % gridSize;
|
|
const colOffset = Math.floor(seed / gridSize) % gridSize;
|
|
return Array.from({ length: gridSize * gridSize }, (_, index) => {
|
|
const row = Math.floor(index / gridSize);
|
|
const col = index % gridSize;
|
|
return {
|
|
row: (rowFromRow * row + rowFromCol * col + rowOffset) % gridSize,
|
|
col: (colFromRow * row + colFromCol * col + colOffset) % gridSize,
|
|
};
|
|
});
|
|
}
|
|
|
|
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<PuzzleCellPosition | null> = Array.from(
|
|
{ length: total },
|
|
() => null,
|
|
);
|
|
const usedCells = new Set<string>();
|
|
|
|
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<PuzzleCellPosition | null>,
|
|
) {
|
|
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<string>();
|
|
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 {
|
|
const timedRun = withResolvedTimer(run);
|
|
if (!timedRun.currentLevel || timedRun.currentLevel.status === 'failed') {
|
|
return run;
|
|
}
|
|
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
|
const nextClearedLevelCount =
|
|
status === 'cleared' && timedRun.currentLevel.status !== 'cleared'
|
|
? timedRun.clearedLevelCount + 1
|
|
: timedRun.clearedLevelCount;
|
|
const justCleared =
|
|
status === 'cleared' && timedRun.currentLevel.status !== 'cleared';
|
|
const nowMs = Date.now();
|
|
const clearedAtMs = justCleared
|
|
? nowMs
|
|
: (timedRun.currentLevel.clearedAtMs ?? null);
|
|
const elapsedMs = justCleared
|
|
? clampElapsedMs(resolveEffectiveElapsedMs(timedRun.currentLevel, nowMs))
|
|
: (timedRun.currentLevel.elapsedMs ?? null);
|
|
return {
|
|
...timedRun,
|
|
clearedLevelCount: nextClearedLevelCount,
|
|
currentLevel: {
|
|
...timedRun.currentLevel,
|
|
board: nextBoard,
|
|
status,
|
|
clearedAtMs,
|
|
elapsedMs,
|
|
remainingMs: justCleared ? 0 : timedRun.currentLevel.remainingMs,
|
|
leaderboardEntries: justCleared
|
|
? []
|
|
: timedRun.currentLevel.leaderboardEntries,
|
|
},
|
|
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
|
recommendedNextProfileId: run.recommendedNextProfileId,
|
|
nextLevelMode: run.nextLevelMode ?? 'none',
|
|
nextLevelProfileId: run.nextLevelProfileId ?? null,
|
|
nextLevelId: run.nextLevelId ?? null,
|
|
recommendedNextWorks: run.recommendedNextWorks ?? [],
|
|
};
|
|
}
|
|
|
|
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
|
return `${entryProfileId}::local-level-${levelIndex}`;
|
|
}
|
|
|
|
function buildLocalLeaderboardEntries(
|
|
nickname: string,
|
|
elapsedMs: number,
|
|
): PuzzleLeaderboardEntry[] {
|
|
return [
|
|
{
|
|
rank: 1,
|
|
nickname,
|
|
elapsedMs,
|
|
visibleTags: [],
|
|
isCurrentPlayer: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
|
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
|
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
|
}
|
|
|
|
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
|
function resolveWorkLevelIndexById(
|
|
levels: PuzzleDraftLevel[] | undefined,
|
|
levelId: string | null | undefined,
|
|
) {
|
|
if (!levelId) {
|
|
return -1;
|
|
}
|
|
return levels?.findIndex((level) => level.levelId === levelId) ?? -1;
|
|
}
|
|
|
|
function resolveWorkLevelById(
|
|
levels: PuzzleDraftLevel[] | undefined,
|
|
levelId: string | null | undefined,
|
|
) {
|
|
const levelIndex = resolveWorkLevelIndexById(levels, levelId);
|
|
return levelIndex >= 0 ? (levels?.[levelIndex] ?? null) : null;
|
|
}
|
|
|
|
function resolveNextSameWorkLevel(
|
|
work: PuzzleWorkSummary | null | undefined,
|
|
currentLevel: PuzzleRuntimeLevelSnapshot,
|
|
) {
|
|
const levels = work?.levels;
|
|
if (!levels?.length) {
|
|
return null;
|
|
}
|
|
|
|
const currentLevelIndexById = resolveWorkLevelIndexById(
|
|
levels,
|
|
currentLevel.levelId,
|
|
);
|
|
const nextLevelIndex =
|
|
currentLevelIndexById >= 0
|
|
? currentLevelIndexById + 1
|
|
: currentLevel.levelIndex;
|
|
return levels[nextLevelIndex] ?? null;
|
|
}
|
|
|
|
function applyLocalNextLevelHandoff(
|
|
run: PuzzleRunSnapshot,
|
|
work: PuzzleWorkSummary | null | undefined,
|
|
currentLevel: PuzzleRuntimeLevelSnapshot,
|
|
) {
|
|
const nextLevel = resolveNextSameWorkLevel(work, currentLevel);
|
|
return {
|
|
...run,
|
|
nextLevelMode: nextLevel ? ('sameWork' as const) : ('none' as const),
|
|
nextLevelProfileId: nextLevel ? currentLevel.profileId : null,
|
|
nextLevelId: nextLevel?.levelId ?? null,
|
|
recommendedNextProfileId: nextLevel ? currentLevel.profileId : null,
|
|
recommendedNextWorks: [],
|
|
};
|
|
}
|
|
|
|
function buildFallbackLocalLevel(
|
|
run: PuzzleRunSnapshot,
|
|
work?: PuzzleWorkSummary | null,
|
|
target?: { profileId?: string; levelId?: string | null },
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'cleared') {
|
|
return run;
|
|
}
|
|
|
|
const nextLevelIndex = run.currentLevelIndex + 1;
|
|
const gridSize = resolvePuzzleLevelConfig(nextLevelIndex).gridSize;
|
|
const nextProfileId =
|
|
target?.profileId?.trim() ||
|
|
run.nextLevelProfileId ||
|
|
run.recommendedNextProfileId ||
|
|
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
|
const nextLevel =
|
|
resolveWorkLevelById(work?.levels, target?.levelId ?? run.nextLevelId) ??
|
|
resolveNextSameWorkLevel(work, currentLevel);
|
|
const startedAtMs = Date.now();
|
|
const nextLevelName =
|
|
nextLevel?.levelName ??
|
|
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
|
|
const nextCoverImageSrc =
|
|
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
|
|
|
|
const nextRun: PuzzleRunSnapshot = {
|
|
...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,
|
|
levelId: nextLevel?.levelId ?? null,
|
|
gridSize,
|
|
profileId: nextProfileId,
|
|
levelName: nextLevelName,
|
|
board: buildInitialBoard(
|
|
gridSize,
|
|
run.runId,
|
|
nextProfileId,
|
|
nextLevelIndex,
|
|
),
|
|
status: 'playing',
|
|
startedAtMs,
|
|
clearedAtMs: null,
|
|
elapsedMs: null,
|
|
coverImageSrc: nextCoverImageSrc,
|
|
...buildLevelTimerFields(nextLevelIndex),
|
|
leaderboardEntries: [],
|
|
},
|
|
leaderboardEntries: [],
|
|
};
|
|
|
|
if (!nextRun.currentLevel) {
|
|
return nextRun;
|
|
}
|
|
return applyLocalNextLevelHandoff(nextRun, work, nextRun.currentLevel);
|
|
}
|
|
|
|
export function startLocalPuzzleRun(
|
|
item: PuzzleWorkSummary,
|
|
levelId?: string | null,
|
|
): PuzzleRunSnapshot {
|
|
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
|
|
const runId = buildLocalPuzzleRunId(item.profileId);
|
|
const startedAtMs = Date.now();
|
|
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
|
|
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
|
|
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
|
const firstLevelName = firstLevel?.levelName || item.levelName;
|
|
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
|
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
|
return {
|
|
runId,
|
|
entryProfileId: item.profileId,
|
|
clearedLevelCount: 0,
|
|
currentLevelIndex: 1,
|
|
currentGridSize: gridSize,
|
|
playedProfileIds: [item.profileId],
|
|
previousLevelTags: item.themeTags,
|
|
currentLevel: {
|
|
runId,
|
|
levelIndex: 1,
|
|
levelId: firstLevel?.levelId ?? null,
|
|
gridSize,
|
|
profileId: item.profileId,
|
|
levelName: firstLevelName,
|
|
authorDisplayName: item.authorDisplayName,
|
|
themeTags: item.themeTags,
|
|
coverImageSrc: firstCoverImageSrc,
|
|
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
|
status: 'playing',
|
|
startedAtMs,
|
|
clearedAtMs: null,
|
|
elapsedMs: null,
|
|
...buildLevelTimerFields(1),
|
|
leaderboardEntries: [],
|
|
},
|
|
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,
|
|
nextLevelMode: nextSameWorkLevel ? 'sameWork' : 'none',
|
|
nextLevelProfileId: nextSameWorkLevel ? item.profileId : null,
|
|
nextLevelId: nextSameWorkLevel?.levelId ?? null,
|
|
recommendedNextWorks: [],
|
|
leaderboardEntries: [],
|
|
};
|
|
}
|
|
|
|
export function swapLocalPuzzlePieces(
|
|
run: PuzzleRunSnapshot,
|
|
payload: SwapPuzzlePiecesRequest,
|
|
work?: PuzzleWorkSummary | null,
|
|
): PuzzleRunSnapshot {
|
|
const timedRun = withResolvedTimer(run);
|
|
const currentLevel = timedRun.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'playing') {
|
|
return timedRun;
|
|
}
|
|
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 timedRun;
|
|
}
|
|
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
|
first.currentRow = second.currentRow;
|
|
first.currentCol = second.currentCol;
|
|
second.currentRow = firstPosition.row;
|
|
second.currentCol = firstPosition.col;
|
|
|
|
const nextRun = applyNextBoard(
|
|
timedRun,
|
|
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
|
);
|
|
return nextRun.currentLevel?.status === 'cleared'
|
|
? syncLocalPuzzleRunHandoff(nextRun, work)
|
|
: nextRun;
|
|
}
|
|
|
|
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,
|
|
work?: PuzzleWorkSummary | null,
|
|
): PuzzleRunSnapshot {
|
|
const timedRun = withResolvedTimer(run);
|
|
const currentLevel = timedRun.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'playing') {
|
|
return timedRun;
|
|
}
|
|
if (
|
|
payload.targetRow < 0 ||
|
|
payload.targetCol < 0 ||
|
|
payload.targetRow >= currentLevel.gridSize ||
|
|
payload.targetCol >= currentLevel.gridSize
|
|
) {
|
|
return timedRun;
|
|
}
|
|
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
|
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
|
if (!moving) {
|
|
return timedRun;
|
|
}
|
|
|
|
if (moving.mergedGroupId) {
|
|
const moved = dragGroup(
|
|
pieces,
|
|
moving,
|
|
payload.targetRow,
|
|
payload.targetCol,
|
|
currentLevel.gridSize,
|
|
);
|
|
if (!moved) {
|
|
return timedRun;
|
|
}
|
|
} else {
|
|
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
|
}
|
|
|
|
const nextRun = applyNextBoard(
|
|
timedRun,
|
|
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
|
|
);
|
|
return nextRun.currentLevel?.status === 'cleared'
|
|
? syncLocalPuzzleRunHandoff(nextRun, work)
|
|
: nextRun;
|
|
}
|
|
|
|
export function advanceLocalPuzzleLevel(
|
|
run: PuzzleRunSnapshot,
|
|
work?: PuzzleWorkSummary | null,
|
|
target?: { profileId?: string; levelId?: string | null },
|
|
): PuzzleRunSnapshot {
|
|
return buildFallbackLocalLevel(run, work, target);
|
|
}
|
|
|
|
export function syncLocalPuzzleRunHandoff(
|
|
run: PuzzleRunSnapshot,
|
|
work: PuzzleWorkSummary | null | undefined,
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel) {
|
|
return run;
|
|
}
|
|
return applyLocalNextLevelHandoff(run, work, currentLevel);
|
|
}
|
|
|
|
export function restartLocalPuzzleLevel(
|
|
run: PuzzleRunSnapshot,
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel) {
|
|
return run;
|
|
}
|
|
|
|
const runId = buildLocalPuzzleRunId(currentLevel.profileId);
|
|
const startedAtMs = Date.now();
|
|
return {
|
|
...run,
|
|
runId,
|
|
leaderboardEntries: [],
|
|
currentLevel: {
|
|
...currentLevel,
|
|
runId,
|
|
board: buildInitialBoard(
|
|
currentLevel.gridSize,
|
|
runId,
|
|
currentLevel.profileId,
|
|
currentLevel.levelIndex,
|
|
),
|
|
status: 'playing',
|
|
startedAtMs,
|
|
clearedAtMs: null,
|
|
elapsedMs: null,
|
|
...buildLevelTimerFields(currentLevel.levelIndex),
|
|
leaderboardEntries: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 判断当前拼图运行态是否为前端本地兜底 run。
|
|
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
|
|
*/
|
|
export function isLocalPuzzleRun(run: PuzzleRunSnapshot | null | undefined) {
|
|
return Boolean(run?.runId?.startsWith(LOCAL_PUZZLE_RUN_ID_PREFIX));
|
|
}
|
|
|
|
/**
|
|
* 本地拼图 run 的排行榜兜底。
|
|
* 当前版本只写入当前玩家成绩,避免结算阶段继续请求后端导致“run 不存在”。
|
|
*/
|
|
export function submitLocalPuzzleLeaderboard(
|
|
run: PuzzleRunSnapshot,
|
|
nickname: string,
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (
|
|
!currentLevel ||
|
|
currentLevel.status !== 'cleared' ||
|
|
currentLevel.elapsedMs === null
|
|
) {
|
|
return run;
|
|
}
|
|
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
|
|
return run;
|
|
}
|
|
|
|
const leaderboardEntries = buildLocalLeaderboardEntries(
|
|
nickname,
|
|
currentLevel.elapsedMs,
|
|
);
|
|
return {
|
|
...run,
|
|
leaderboardEntries,
|
|
currentLevel: {
|
|
...currentLevel,
|
|
leaderboardEntries,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function refreshLocalPuzzleTimer(
|
|
run: PuzzleRunSnapshot,
|
|
): PuzzleRunSnapshot {
|
|
return withResolvedTimer(run);
|
|
}
|
|
|
|
export function setLocalPuzzlePaused(
|
|
run: PuzzleRunSnapshot,
|
|
paused: boolean,
|
|
): PuzzleRunSnapshot {
|
|
const timedRun = withResolvedTimer(run);
|
|
const currentLevel = timedRun.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'playing') {
|
|
return timedRun;
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
if (paused) {
|
|
return {
|
|
...timedRun,
|
|
currentLevel: {
|
|
...currentLevel,
|
|
pauseStartedAtMs: currentLevel.pauseStartedAtMs ?? nowMs,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
...timedRun,
|
|
currentLevel: closePauseForLevel(currentLevel, nowMs),
|
|
};
|
|
}
|
|
|
|
export function applyLocalPuzzleFreezeTime(
|
|
run: PuzzleRunSnapshot,
|
|
): PuzzleRunSnapshot {
|
|
const timedRun = withResolvedTimer(run);
|
|
const currentLevel = timedRun.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'playing') {
|
|
return timedRun;
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
const activeLevel = closePauseForLevel(currentLevel, nowMs);
|
|
return {
|
|
...timedRun,
|
|
currentLevel: {
|
|
...activeLevel,
|
|
freezeStartedAtMs: nowMs,
|
|
freezeUntilMs: nowMs + PUZZLE_FREEZE_TIME_DURATION_MS,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function extendLocalPuzzleTime(
|
|
run: PuzzleRunSnapshot,
|
|
): PuzzleRunSnapshot {
|
|
const timedRun = withResolvedTimer(run);
|
|
const currentLevel = timedRun.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'failed') {
|
|
return timedRun;
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
const consumedBeforeExtend = Math.max(
|
|
0,
|
|
currentLevel.timeLimitMs - PUZZLE_EXTEND_TIME_DURATION_MS,
|
|
);
|
|
|
|
return {
|
|
...timedRun,
|
|
currentLevel: {
|
|
...currentLevel,
|
|
status: 'playing',
|
|
startedAtMs: nowMs - consumedBeforeExtend,
|
|
clearedAtMs: null,
|
|
elapsedMs: null,
|
|
remainingMs: PUZZLE_EXTEND_TIME_DURATION_MS,
|
|
pausedAccumulatedMs: 0,
|
|
pauseStartedAtMs: null,
|
|
freezeAccumulatedMs: 0,
|
|
freezeStartedAtMs: null,
|
|
freezeUntilMs: null,
|
|
},
|
|
};
|
|
}
|