Files
Genarrative/src/services/puzzle-runtime/puzzleLocalRuntime.ts
2026-04-29 20:56:59 +08:00

883 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleGridSize,
PuzzleLeaderboardEntry,
PuzzleMergedGroupState,
PuzzlePieceState,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
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_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE: Record<PuzzleGridSize, number> = {
3: 180_000,
4: 300_000,
};
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
return clearedLevelCount >= 3 ? 4 : 3;
}
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
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 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(gridSize: PuzzleGridSize) {
return PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE[gridSize];
}
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(gridSize: PuzzleGridSize) {
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(gridSize);
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 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<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:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
: run.recommendedNextProfileId,
};
}
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
return `${entryProfileId}::local-level-${levelIndex}`;
}
function buildLocalLeaderboardEntries(
nickname: string,
elapsedMs: number,
): PuzzleLeaderboardEntry[] {
return [
{
rank: 1,
nickname,
elapsedMs,
isCurrentPlayer: true,
},
];
}
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
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,
...buildLevelTimerFields(gridSize),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
leaderboardEntries: [],
};
}
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
const gridSize = resolvePuzzleGridSize(0);
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${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,
...buildLevelTimerFields(gridSize),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
leaderboardEntries: [],
};
}
export function swapLocalPuzzlePieces(
run: PuzzleRunSnapshot,
payload: SwapPuzzlePiecesRequest,
): 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;
return applyNextBoard(timedRun, 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 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);
}
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
return buildFallbackLocalLevel(run);
}
/**
* 判断当前拼图运行态是否为前端本地兜底 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,
},
};
}