This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -7,4 +7,6 @@ export {
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from './puzzleRuntimeClient';

View File

@@ -3,9 +3,12 @@
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
@@ -89,10 +92,9 @@ describe('puzzleLocalRuntime', () => {
piece.currentRow,
piece.currentCol,
]);
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
piece.currentRow,
piece.currentCol,
]);
const secondPositions = secondRun.currentLevel?.board.pieces.map(
(piece) => [piece.currentRow, piece.currentCol],
);
expect(firstPositions).not.toEqual(secondPositions);
});
@@ -133,7 +135,10 @@ describe('puzzleLocalRuntime', () => {
'piece-7': [1, 2],
'piece-8': [2, 1],
};
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
const current = layout[piece.pieceId] ?? [
piece.currentRow,
piece.currentCol,
];
return {
...piece,
currentRow: current[0],
@@ -266,7 +271,9 @@ describe('puzzleLocalRuntime', () => {
return;
}
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
const occupiedCells = nextBoard.pieces.map(
(piece) => `${piece.currentRow}:${piece.currentCol}`,
);
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
@@ -289,7 +296,9 @@ describe('puzzleLocalRuntime', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
expect(clearedRun.recommendedNextProfileId).toBe(
'profile-1::local-level-2',
);
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(clearedRun.leaderboardEntries).toEqual([]);
@@ -313,9 +322,15 @@ describe('puzzleLocalRuntime', () => {
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);
expect(boardPositionSignature(secondRun)).not.toBe(
boardPositionSignature(thirdRun),
);
expect(
hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? []),
).toBe(false);
expect(
hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? []),
).toBe(false);
});
test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => {
@@ -338,4 +353,77 @@ describe('puzzleLocalRuntime', () => {
leaderboardRun.leaderboardEntries,
);
});
test('本地倒计时超时后进入失败状态并拒绝继续移动', () => {
const run = startLocalPuzzleRun(baseWork);
const expiredRun = {
...run,
currentLevel: run.currentLevel
? {
...run.currentLevel,
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
}
: null,
};
const timedRun = refreshLocalPuzzleTimer(expiredRun);
const nextRun = dragLocalPuzzlePiece(timedRun, {
pieceId: 'piece-0',
targetRow: 0,
targetCol: 0,
});
expect(timedRun.currentLevel?.status).toBe('failed');
expect(timedRun.currentLevel?.remainingMs).toBe(0);
expect(nextRun).toBe(timedRun);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(
{
...run,
currentLevel: run.currentLevel
? {
...run.currentLevel,
startedAtMs: Date.now() - 5_000,
}
: null,
},
true,
);
const pausedStartedAt =
pausedRun.currentLevel?.pauseStartedAtMs ?? Date.now();
const pausedAfterWait = refreshLocalPuzzleTimer({
...pausedRun,
currentLevel: pausedRun.currentLevel
? {
...pausedRun.currentLevel,
startedAtMs: pausedRun.currentLevel.startedAtMs - 5_000,
pauseStartedAtMs: pausedStartedAt - 5_000,
}
: null,
});
const frozenRun = applyLocalPuzzleFreezeTime(pausedAfterWait);
const freezeStartedAt =
frozenRun.currentLevel?.freezeStartedAtMs ?? Date.now();
const frozenAfterWait = refreshLocalPuzzleTimer({
...frozenRun,
currentLevel: frozenRun.currentLevel
? {
...frozenRun.currentLevel,
startedAtMs: frozenRun.currentLevel.startedAtMs - 5_000,
freezeStartedAtMs: freezeStartedAt - 5_000,
}
: null,
});
expect(pausedAfterWait.currentLevel?.remainingMs).toBe(
pausedRun.currentLevel?.remainingMs,
);
expect(frozenAfterWait.currentLevel?.remainingMs).toBe(
frozenRun.currentLevel?.remainingMs,
);
expect(frozenAfterWait.currentLevel?.pauseStartedAtMs).toBeNull();
});
});

View File

@@ -7,11 +7,17 @@ import type {
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;
@@ -92,6 +98,100 @@ 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,
@@ -365,30 +465,37 @@ function applyNextBoard(
run: PuzzleRunSnapshot,
nextBoard: PuzzleBoardSnapshot,
): PuzzleRunSnapshot {
if (!run.currentLevel) {
const timedRun = withResolvedTimer(run);
if (!timedRun.currentLevel || timedRun.currentLevel.status === 'failed') {
return run;
}
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
const nextClearedLevelCount =
status === 'cleared' && run.currentLevel.status !== 'cleared'
? run.clearedLevelCount + 1
: run.clearedLevelCount;
const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared';
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 : (run.currentLevel.clearedAtMs ?? null);
const clearedAtMs = justCleared
? nowMs
: (timedRun.currentLevel.clearedAtMs ?? null);
const elapsedMs = justCleared
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
: (run.currentLevel.elapsedMs ?? null);
? clampElapsedMs(resolveEffectiveElapsedMs(timedRun.currentLevel, nowMs))
: (timedRun.currentLevel.elapsedMs ?? null);
return {
...run,
...timedRun,
clearedLevelCount: nextClearedLevelCount,
currentLevel: {
...run.currentLevel,
...timedRun.currentLevel,
board: nextBoard,
status,
clearedAtMs,
elapsedMs,
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
remainingMs: justCleared ? 0 : timedRun.currentLevel.remainingMs,
leaderboardEntries: justCleared
? []
: timedRun.currentLevel.leaderboardEntries,
},
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
recommendedNextProfileId:
@@ -455,6 +562,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(gridSize),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
@@ -488,6 +596,7 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(gridSize),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
@@ -499,15 +608,16 @@ export function swapLocalPuzzlePieces(
run: PuzzleRunSnapshot,
payload: SwapPuzzlePiecesRequest,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return run;
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 run;
return timedRun;
}
const firstPosition = { row: first.currentRow, col: first.currentCol };
first.currentRow = second.currentRow;
@@ -515,7 +625,7 @@ export function swapLocalPuzzlePieces(
second.currentRow = firstPosition.row;
second.currentCol = firstPosition.col;
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
function dragSinglePiece(
@@ -636,9 +746,10 @@ export function dragLocalPuzzlePiece(
run: PuzzleRunSnapshot,
payload: DragPuzzlePieceRequest,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel || currentLevel.status === 'cleared') {
return run;
const timedRun = withResolvedTimer(run);
const currentLevel = timedRun.currentLevel;
if (!currentLevel || currentLevel.status !== 'playing') {
return timedRun;
}
if (
payload.targetRow < 0 ||
@@ -646,12 +757,12 @@ export function dragLocalPuzzlePiece(
payload.targetRow >= currentLevel.gridSize ||
payload.targetCol >= currentLevel.gridSize
) {
return run;
return timedRun;
}
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
if (!moving) {
return run;
return timedRun;
}
if (moving.mergedGroupId) {
@@ -663,13 +774,13 @@ export function dragLocalPuzzlePiece(
currentLevel.gridSize,
);
if (!moved) {
return run;
return timedRun;
}
} else {
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
}
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
@@ -717,3 +828,55 @@ export function submitLocalPuzzleLeaderboard(
},
};
}
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,
},
};
}

View File

@@ -5,6 +5,8 @@ import type {
StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest,
UpdatePuzzleRuntimePauseRequest,
UsePuzzleRuntimePropRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
@@ -134,6 +136,48 @@ export async function submitPuzzleLeaderboard(
);
}
/**
* 暂停或恢复正式拼图运行态计时。
*/
export async function updatePuzzleRunPause(
runId: string,
payload: UpdatePuzzleRuntimePauseRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新拼图计时状态失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 使用正式拼图道具,服务端负责扣除陶泥币并更新运行态。
*/
export async function usePuzzleRuntimeProp(
runId: string,
payload: UsePuzzleRuntimePropRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'使用拼图道具失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
*/
@@ -162,4 +206,6 @@ export const puzzleRuntimeClient = {
submitLeaderboard: submitPuzzleLeaderboard,
startRun: startPuzzleRun,
swap: swapPuzzlePieces,
updatePause: updatePuzzleRunPause,
useProp: usePuzzleRuntimeProp,
};