1
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user