1
This commit is contained in:
@@ -5,5 +5,6 @@ export {
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -27,7 +27,7 @@ const baseWork: PuzzleWorkSummary = {
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
return pieces.some((piece) =>
|
||||
pieces.some((candidate) => {
|
||||
if (piece.pieceId === candidate.pieceId) {
|
||||
@@ -39,13 +39,18 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const correctColDelta = candidate.correctCol - piece.correctCol;
|
||||
return (
|
||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||
currentRowDelta === correctRowDelta &&
|
||||
currentColDelta === correctColDelta
|
||||
Math.abs(correctRowDelta) + Math.abs(correctColDelta) === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
return run.currentLevel?.board.pieces
|
||||
.map((piece) => `${piece.currentRow}:${piece.currentCol}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
@@ -89,13 +94,13 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
|
||||
test('初始棋盘没有任何自动合并块', () => {
|
||||
test('初始棋盘没有任何原图相邻块贴边', () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
|
||||
expect(board?.mergedGroups).toEqual([]);
|
||||
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,12 +288,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
clearedRun.currentLevel?.leaderboardEntries.some(
|
||||
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
@@ -300,4 +301,17 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
|
||||
test('连续推进下一关会重新打乱棋盘', () => {
|
||||
const firstClearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
const secondRun = advanceLocalPuzzleLevel(firstClearedRun);
|
||||
const secondClearedRun = solveCurrentLevel(secondRun);
|
||||
const thirdRun = advanceLocalPuzzleLevel(secondClearedRun);
|
||||
|
||||
expect(secondRun.currentLevelIndex).toBe(2);
|
||||
expect(thirdRun.currentLevelIndex).toBe(3);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleGridSize,
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
);
|
||||
ensureBoardIsNotSolved(shuffled, gridSize);
|
||||
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
||||
if (!hasAnyCorrectNeighborPair(pieces)) {
|
||||
if (!hasAnyOriginalNeighborPair(pieces)) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return positions.slice().reverse();
|
||||
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
@@ -90,48 +89,6 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function rankLeaderboardEntries(
|
||||
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
return entries
|
||||
.map((entry) => ({ ...entry }))
|
||||
.sort((left, right) => left.elapsedMs - right.elapsedMs)
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
|
||||
function buildLocalLeaderboardEntries(
|
||||
elapsedMs: number,
|
||||
playerNickname: string,
|
||||
levelIndex: number,
|
||||
gridSize: PuzzleGridSize,
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
|
||||
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
|
||||
return rankLeaderboardEntries([
|
||||
{
|
||||
nickname: playerNickname.trim() || '玩家',
|
||||
elapsedMs: normalizedElapsedMs,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
|
||||
},
|
||||
{
|
||||
nickname: '月港拼图手',
|
||||
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
|
||||
},
|
||||
{
|
||||
nickname: '雾灯收藏家',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
@@ -179,6 +136,119 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
);
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -306,15 +376,6 @@ function applyNextBoard(
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
const leaderboardEntries =
|
||||
justCleared && elapsedMs
|
||||
? buildLocalLeaderboardEntries(
|
||||
elapsedMs,
|
||||
run.currentLevel.authorDisplayName,
|
||||
run.currentLevel.levelIndex,
|
||||
run.currentLevel.gridSize,
|
||||
)
|
||||
: run.currentLevel.leaderboardEntries;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
@@ -324,9 +385,9 @@ function applyNextBoard(
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
@@ -112,6 +113,27 @@ export async function advancePuzzleNextLevel(runId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交通关成绩并读取真实排行榜。
|
||||
*/
|
||||
export async function submitPuzzleLeaderboard(
|
||||
runId: string,
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
|
||||
*/
|
||||
@@ -137,6 +159,7 @@ export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user