Files
Genarrative/src/services/puzzle-runtime/puzzleLocalRuntime.ts
T
2026-05-10 22:20:54 +08:00

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,
},
};
}