Files
Genarrative/src/services/puzzle-clear/puzzleClearLocalRuntime.ts
T

860 lines
24 KiB
TypeScript

import type {
PuzzleClearBoardCell,
PuzzleClearBoardSnapshot,
PuzzleClearCardAsset,
PuzzleClearRuntimeSnapshotResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
const PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS = 600;
const PUZZLE_CLEAR_LOCAL_MAX_LEVEL = 1;
const PUZZLE_CLEAR_LEVEL_CONFIGS = [
{ size: 6, targetClears: 35 },
] as const;
type PuzzleClearLevelConfig = (typeof PUZZLE_CLEAR_LEVEL_CONFIGS)[number];
const PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG: PuzzleClearLevelConfig =
PUZZLE_CLEAR_LEVEL_CONFIGS[PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1]!;
const PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES: readonly PuzzleClearCardAsset['shape'][][] =
[
['1x2', '1x3', '2x2', '2x3'],
];
type PuzzleClearLocalRuntimeBoardCell = {
row: number;
col: number;
card: PuzzleClearCardAsset | null;
lockedGroupId: string | null;
};
type PuzzleClearLocalRuntimeElimination = {
groupId: string;
positions: Array<{ row: number; col: number }>;
};
function cloneCard(card: PuzzleClearCardAsset | null): PuzzleClearCardAsset | null {
return card ? { ...card } : null;
}
function cloneBoardCell(cell: PuzzleClearLocalRuntimeBoardCell) {
return {
row: cell.row,
col: cell.col,
card: cloneCard(cell.card),
lockedGroupId: cell.lockedGroupId,
};
}
function resolvePuzzleClearLevelConfig(
levelIndex: number,
): PuzzleClearLevelConfig {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1,
Math.max(0, levelIndex - 1),
);
return (
PUZZLE_CLEAR_LEVEL_CONFIGS[normalizedLevelIndex] ??
PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG
);
}
function resolvePuzzleClearLevelUnlockedShapes(
levelIndex: number,
): readonly PuzzleClearCardAsset['shape'][] {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1,
Math.max(0, levelIndex - 1),
);
return (
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[normalizedLevelIndex] ??
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1]!
);
}
function stablePuzzleClearGroupKey(seed: string, groupId: string) {
let state = 0xcbf2_9ce4_8422_2325n;
for (const character of `${seed}${groupId}`) {
state ^= BigInt(character.charCodeAt(0));
state = (state * 0x1000_0000_01b3n) & 0xffff_ffff_ffff_ffffn;
}
return state;
}
function buildPuzzleClearLevelCards(
work: PuzzleClearWorkProfileResponse,
levelIndex: number,
) {
const cards = work.cardAssets.length > 0 ? work.cardAssets : work.draft.cardAssets;
const unlockedShapes = new Set(resolvePuzzleClearLevelUnlockedShapes(levelIndex));
const targetGroups = resolvePuzzleClearLevelConfig(levelIndex).targetClears;
const groupedCards = new Map<string, PuzzleClearCardAsset[]>();
for (const card of cards) {
if (!unlockedShapes.has(card.shape)) {
continue;
}
const entries = groupedCards.get(card.groupId) ?? [];
entries.push({ ...card });
groupedCards.set(card.groupId, entries);
}
return [...groupedCards.entries()]
.map(([groupId, entries]) => ({
groupId,
cards: entries.sort((left, right) => left.partY - right.partY || left.partX - right.partX),
}))
.sort((left, right) => {
const leftKey = stablePuzzleClearGroupKey(work.summary.profileId, left.groupId);
const rightKey = stablePuzzleClearGroupKey(work.summary.profileId, right.groupId);
if (leftKey === rightKey) {
return left.groupId.localeCompare(right.groupId);
}
return leftKey < rightKey ? -1 : 1;
})
.slice(0, targetGroups)
.flatMap((group) => group.cards);
}
function shufflePuzzleClearLocalCards(
cards: PuzzleClearCardAsset[],
seed: string,
) {
return [...cards].sort((left, right) => {
const leftKey = stablePuzzleClearGroupKey(
seed,
`${left.groupId}:${left.cardId}:${left.partX}:${left.partY}`,
);
const rightKey = stablePuzzleClearGroupKey(
seed,
`${right.groupId}:${right.cardId}:${right.partX}:${right.partY}`,
);
if (leftKey === rightKey) {
return left.cardId.localeCompare(right.cardId);
}
return leftKey < rightKey ? -1 : 1;
});
}
function buildLocalRuntimeBoard(
cards: PuzzleClearCardAsset[],
levelIndex: number,
seed: string,
) {
const size = resolvePuzzleClearLevelConfig(levelIndex).size;
const visibleCards = cards.slice(0, size * size);
for (let attempt = 0; attempt < 128; attempt += 1) {
const attemptSeed = `${seed}:level-${levelIndex}:attempt-${attempt}`;
const shuffledCards = shufflePuzzleClearLocalCards(visibleCards, attemptSeed);
const boardCells: PuzzleClearLocalRuntimeBoardCell[] = [];
let index = 0;
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
boardCells.push({
row,
col,
card: cloneCard(shuffledCards[index] ?? null),
lockedGroupId: null,
});
index += 1;
}
}
const board = {
rows: size,
cols: size,
cells: boardCells,
} satisfies PuzzleClearBoardSnapshot;
if (
findPuzzleClearLocalEliminations(board).length === 0 &&
hasPuzzleClearLocalPotentialMove(board)
) {
return board;
}
}
return {
rows: size,
cols: size,
cells: Array.from({ length: size * size }, (_, index) => {
const row = Math.floor(index / size);
const col = index % size;
return {
row,
col,
card: cloneCard(visibleCards[index] ?? null),
lockedGroupId: null,
};
}),
} satisfies PuzzleClearBoardSnapshot;
}
function buildLocalRuntimeReadyColumns(
cards: PuzzleClearCardAsset[],
columnCount: number,
) {
const readyColumns = Array.from({ length: columnCount }, () => [] as PuzzleClearCardAsset[]);
const boardCardCount = columnCount * columnCount;
const reserveCards = cards.slice(boardCardCount);
reserveCards.forEach((card, index) => {
readyColumns[index % columnCount]?.push({ ...card });
});
return readyColumns;
}
function cloneRun(
run: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearRuntimeSnapshotResponse {
return {
...run,
board: {
...run.board,
cells: run.board.cells.map(cloneBoardCell),
},
readyColumns: run.readyColumns.map((column) =>
column.map((card) => ({ ...card })),
),
};
}
function findCell(
board: PuzzleClearBoardSnapshot,
row: number,
col: number,
) {
return board.cells.find((cell) => cell.row === row && cell.col === col) ?? null;
}
function resolveShapeDimensions(card: PuzzleClearCardAsset) {
const baseDimensions: Record<PuzzleClearCardAsset['shape'], [number, number]> = {
'1x2': [2, 1],
'1x3': [3, 1],
'2x2': [2, 2],
'2x3': [3, 2],
};
const [width, height] = baseDimensions[card.shape];
const canRotate = card.shape === '1x2' || card.shape === '1x3' || card.shape === '2x3';
if (card.orientation === 'vertical' && canRotate) {
return { width: height, height: width };
}
return { width, height };
}
function getPartDistance(left: PuzzleClearCardAsset, right: PuzzleClearCardAsset) {
return Math.abs(left.partX - right.partX) + Math.abs(left.partY - right.partY);
}
function areNeighborCells(
left: { row: number; col: number },
right: { row: number; col: number },
) {
return Math.abs(left.row - right.row) + Math.abs(left.col - right.col) === 1;
}
function positionKey(row: number, col: number) {
return `${row}:${col}`;
}
function partPositionKey(row: number, col: number, partX: number, partY: number) {
return `${row}:${col}:${partX}:${partY}`;
}
function getCardAt(board: PuzzleClearBoardSnapshot, row: number, col: number) {
return findCell(board, row, col)?.card ?? null;
}
function findPuzzleClearLocalEliminations(
board: PuzzleClearBoardSnapshot,
): PuzzleClearLocalRuntimeElimination[] {
const byGroup = new Map<
string,
Array<{ row: number; col: number; card: PuzzleClearCardAsset }>
>();
for (const cell of board.cells) {
if (!cell.card) {
continue;
}
const entries = byGroup.get(cell.card.groupId) ?? [];
entries.push({ row: cell.row, col: cell.col, card: cell.card });
byGroup.set(cell.card.groupId, entries);
}
const eliminations: PuzzleClearLocalRuntimeElimination[] = [];
for (const [groupId, entries] of byGroup) {
const first = entries[0]?.card;
if (!first) {
continue;
}
const { width, height } = resolveShapeDimensions(first);
if (entries.length !== width * height) {
continue;
}
const minRow = Math.min(...entries.map((entry) => entry.row));
const minCol = Math.min(...entries.map((entry) => entry.col));
const expected = new Set<string>();
for (let partY = 0; partY < height; partY += 1) {
for (let partX = 0; partX < width; partX += 1) {
expected.add(partPositionKey(minRow + partY, minCol + partX, partX, partY));
}
}
const actual = new Set(
entries.map((entry) =>
partPositionKey(entry.row, entry.col, entry.card.partX, entry.card.partY),
),
);
if (expected.size === actual.size && [...expected].every((key) => actual.has(key))) {
eliminations.push({
groupId,
positions: entries.map((entry) => ({ row: entry.row, col: entry.col })),
});
}
}
return eliminations;
}
function findPuzzleClearLocalCompletedPartialGroups(
board: PuzzleClearBoardSnapshot,
): PuzzleClearLocalRuntimeElimination[] {
const byGroup = new Map<
string,
Array<{ row: number; col: number; card: PuzzleClearCardAsset }>
>();
for (const cell of board.cells) {
if (!cell.card) {
continue;
}
const entries = byGroup.get(cell.card.groupId) ?? [];
entries.push({ row: cell.row, col: cell.col, card: cell.card });
byGroup.set(cell.card.groupId, entries);
}
const groups: PuzzleClearLocalRuntimeElimination[] = [];
for (const [groupId, entries] of byGroup) {
const first = entries[0]?.card;
if (!first || first.shape === '1x2' || entries.length < 2) {
continue;
}
const ordered = [...entries].sort(
(left, right) =>
left.card.partY - right.card.partY || left.card.partX - right.card.partX,
);
const adjacent = ordered.slice(1).every((entry, index) => {
const previous = ordered[index]!;
return (
getPartDistance(previous.card, entry.card) === 1 &&
areNeighborCells(previous, entry)
);
});
if (adjacent) {
groups.push({
groupId,
positions: entries.map((entry) => ({ row: entry.row, col: entry.col })),
});
}
}
return groups;
}
function markPuzzleClearLocalCompletedGroups(board: PuzzleClearBoardSnapshot) {
for (const group of findPuzzleClearLocalCompletedPartialGroups(board)) {
for (const position of group.positions) {
const cell = findCell(board, position.row, position.col);
if (cell) {
cell.lockedGroupId = group.groupId;
}
}
}
}
function clearPuzzleClearLocalLockedGroup(
board: PuzzleClearBoardSnapshot,
groupId: string,
) {
for (const cell of board.cells) {
if (cell.lockedGroupId === groupId) {
cell.lockedGroupId = null;
}
}
}
function clearPuzzleClearLocalElimination(
board: PuzzleClearBoardSnapshot,
elimination: PuzzleClearLocalRuntimeElimination,
) {
for (const position of elimination.positions) {
const cell = findCell(board, position.row, position.col);
if (cell) {
cell.card = null;
cell.lockedGroupId = null;
}
}
}
function findPuzzleClearLocalMatchingRefillIndex(
column: PuzzleClearCardAsset[] | undefined,
board: PuzzleClearBoardSnapshot,
) {
if (!column || column.length === 0) {
return -1;
}
return column.findIndex((candidate) =>
board.cells.some((cell) => {
const card = cell.card;
return (
card?.groupId === candidate.groupId && getPartDistance(card, candidate) === 1
);
}),
);
}
function popPuzzleClearLocalRefillCard(
readyColumns: PuzzleClearCardAsset[][],
preferredCol: number,
board: PuzzleClearBoardSnapshot,
) {
const preferredColumn = readyColumns[preferredCol];
const matchingIndex = findPuzzleClearLocalMatchingRefillIndex(
preferredColumn,
board,
);
if (matchingIndex >= 0) {
return preferredColumn?.splice(matchingIndex, 1)[0] ?? null;
}
for (const [columnIndex, column] of readyColumns.entries()) {
if (columnIndex === preferredCol) {
continue;
}
const fallbackMatchingIndex = findPuzzleClearLocalMatchingRefillIndex(
column,
board,
);
if (fallbackMatchingIndex >= 0) {
return column.splice(fallbackMatchingIndex, 1)[0] ?? null;
}
}
if (preferredColumn && preferredColumn.length > 0) {
return preferredColumn.pop() ?? null;
}
for (const [columnIndex, column] of readyColumns.entries()) {
if (columnIndex === preferredCol) {
continue;
}
const card = column.pop();
if (card) {
return card;
}
}
return null;
}
function refillPuzzleClearLocalColumnSegment(
board: PuzzleClearBoardSnapshot,
readyColumns: PuzzleClearCardAsset[][],
col: number,
startRow: number,
endRow: number,
) {
const existing: PuzzleClearCardAsset[] = [];
for (let row = endRow - 1; row >= startRow; row -= 1) {
const cell = findCell(board, row, col);
if (cell?.card) {
existing.push(cell.card);
cell.card = null;
cell.lockedGroupId = null;
}
}
for (let row = endRow - 1; row >= startRow; row -= 1) {
const cell = findCell(board, row, col);
if (!cell) {
continue;
}
cell.card = existing.shift() ?? popPuzzleClearLocalRefillCard(readyColumns, col, board);
cell.lockedGroupId = null;
}
}
function applyPuzzleClearLocalGravityAndRefill(
board: PuzzleClearBoardSnapshot,
readyColumns: PuzzleClearCardAsset[][],
) {
for (let col = 0; col < board.cols; col += 1) {
let segmentStart = 0;
while (segmentStart < board.rows) {
while (
segmentStart < board.rows &&
Boolean(findCell(board, segmentStart, col)?.lockedGroupId)
) {
segmentStart += 1;
}
const start = segmentStart;
while (
segmentStart < board.rows &&
!findCell(board, segmentStart, col)?.lockedGroupId
) {
segmentStart += 1;
}
if (start < segmentStart) {
refillPuzzleClearLocalColumnSegment(board, readyColumns, col, start, segmentStart);
}
}
}
}
function hasPuzzleClearLocalEmptyCell(board: PuzzleClearBoardSnapshot) {
return board.cells.some((cell) => !cell.card);
}
function hasPuzzleClearLocalRemainingCards(board: PuzzleClearBoardSnapshot) {
return board.cells.some((cell) => Boolean(cell.card));
}
function hasPuzzleClearLocalPotentialMove(board: PuzzleClearBoardSnapshot) {
if (findPuzzleClearLocalEliminations(board).length > 0) {
return false;
}
const sourceCells = board.cells.filter(
(cell): cell is PuzzleClearBoardCell & { card: PuzzleClearCardAsset } =>
Boolean(cell.card),
);
for (const source of sourceCells) {
for (const target of board.cells) {
if (source.row === target.row && source.col === target.col) {
continue;
}
const candidate: PuzzleClearBoardSnapshot = {
rows: board.rows,
cols: board.cols,
cells: board.cells.map(cloneBoardCell),
};
const candidateSource = findCell(candidate, source.row, source.col);
const candidateTarget = findCell(candidate, target.row, target.col);
if (!candidateSource?.card || !candidateTarget) {
continue;
}
if (candidateTarget.card) {
const swappedCard = candidateSource.card;
candidateSource.card = candidateTarget.card;
candidateTarget.card = swappedCard;
} else {
candidateTarget.card = candidateSource.card;
candidateSource.card = null;
}
if (findPuzzleClearLocalEliminations(candidate).length > 0) {
return true;
}
}
}
return false;
}
function ensurePuzzleClearLocalPlayableMove(board: PuzzleClearBoardSnapshot) {
if (!hasPuzzleClearLocalRemainingCards(board) || hasPuzzleClearLocalPotentialMove(board)) {
return;
}
for (const source of board.cells) {
if (source.lockedGroupId || !source.card) {
continue;
}
for (const target of board.cells) {
if (
target.lockedGroupId ||
(source.row === target.row && source.col === target.col)
) {
continue;
}
const sourceCard: PuzzleClearCardAsset | null = source.card;
source.card = target.card;
target.card = sourceCard;
source.lockedGroupId = null;
target.lockedGroupId = null;
if (
findPuzzleClearLocalEliminations(board).length === 0 &&
hasPuzzleClearLocalPotentialMove(board)
) {
return;
}
target.card = source.card;
source.card = sourceCard;
source.lockedGroupId = null;
target.lockedGroupId = null;
}
}
}
function resolvePuzzleClearLocalEliminationsAndRefill(
run: PuzzleClearRuntimeSnapshotResponse,
) {
let resolvedClears = 0;
const maxPasses = run.board.rows * run.board.cols;
for (let pass = 0; pass < maxPasses; pass += 1) {
const eliminations = findPuzzleClearLocalEliminations(run.board);
if (eliminations.length === 0) {
break;
}
for (const elimination of eliminations) {
clearPuzzleClearLocalElimination(run.board, elimination);
run.clearsDone += 1;
resolvedClears += 1;
}
applyPuzzleClearLocalGravityAndRefill(run.board, run.readyColumns);
}
return resolvedClears;
}
function swapPuzzleClearLocalCardPositions(
board: PuzzleClearBoardSnapshot,
fromRow: number,
fromCol: number,
toRow: number,
toCol: number,
) {
const from = findCell(board, fromRow, fromCol);
const to = findCell(board, toRow, toCol);
if (!from || !to) {
return false;
}
const fromCard = from.card;
from.card = to.card;
to.card = fromCard;
from.lockedGroupId = null;
to.lockedGroupId = null;
return true;
}
function movePuzzleClearLocalLockedGroup(
board: PuzzleClearBoardSnapshot,
groupId: string,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
) {
const deltaRow = payload.toRow - payload.fromRow;
const deltaCol = payload.toCol - payload.fromCol;
if (deltaRow === 0 && deltaCol === 0) {
return true;
}
const groupCells = board.cells
.filter((cell) => cell.lockedGroupId === groupId && cell.card)
.map((cell) => ({
row: cell.row,
col: cell.col,
card: cloneCard(cell.card)!,
}));
if (groupCells.length === 0) {
return false;
}
const moved = groupCells.map((cell) => ({
...cell,
nextRow: cell.row + deltaRow,
nextCol: cell.col + deltaCol,
}));
if (
moved.some(
(cell) =>
cell.nextRow < 0 ||
cell.nextCol < 0 ||
cell.nextRow >= board.rows ||
cell.nextCol >= board.cols,
)
) {
return false;
}
const oldKeys = new Set(groupCells.map((cell) => positionKey(cell.row, cell.col)));
const newKeys = new Set(moved.map((cell) => positionKey(cell.nextRow, cell.nextCol)));
const displaced = moved
.map((cell) => findCell(board, cell.nextRow, cell.nextCol))
.filter((cell): cell is PuzzleClearLocalRuntimeBoardCell => Boolean(cell))
.filter((cell) => !oldKeys.has(positionKey(cell.row, cell.col)))
.map((cell) => ({
card: cloneCard(cell.card),
lockedGroupId: cell.lockedGroupId,
}));
for (const cell of moved) {
const target = findCell(board, cell.nextRow, cell.nextCol);
if (target) {
target.card = cloneCard(cell.card);
target.lockedGroupId = groupId;
}
}
let displacedIndex = 0;
for (const cell of groupCells) {
if (newKeys.has(positionKey(cell.row, cell.col))) {
continue;
}
const target = findCell(board, cell.row, cell.col);
const replacement = displaced[displacedIndex];
displacedIndex += 1;
if (target) {
target.card = replacement?.card ?? null;
target.lockedGroupId = replacement?.lockedGroupId ?? null;
}
}
return true;
}
function swapPuzzleClearLocalBoardCells(
board: PuzzleClearBoardSnapshot,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
) {
const from = findCell(board, payload.fromRow, payload.fromCol);
const to = findCell(board, payload.toRow, payload.toCol);
if (!from || !to || !from.card) {
return false;
}
if (from.lockedGroupId) {
return movePuzzleClearLocalLockedGroup(board, from.lockedGroupId, payload);
}
const targetLockedGroupId = to.lockedGroupId;
const sourceCard = cloneCard(from.card);
if (!to.card) {
to.card = sourceCard;
to.lockedGroupId = null;
from.card = null;
from.lockedGroupId = null;
if (targetLockedGroupId) {
clearPuzzleClearLocalLockedGroup(board, targetLockedGroupId);
}
return true;
}
if (
!swapPuzzleClearLocalCardPositions(
board,
payload.fromRow,
payload.fromCol,
payload.toRow,
payload.toCol,
)
) {
return false;
}
if (targetLockedGroupId) {
clearPuzzleClearLocalLockedGroup(board, targetLockedGroupId);
}
return true;
}
export function createPuzzleClearLocalRuntimeSnapshot(
work: PuzzleClearWorkProfileResponse,
levelIndex = 1,
): PuzzleClearRuntimeSnapshotResponse {
const normalizedLevelIndex = Math.min(
PUZZLE_CLEAR_LOCAL_MAX_LEVEL,
Math.max(1, levelIndex),
);
const levelConfig = resolvePuzzleClearLevelConfig(normalizedLevelIndex);
const levelCards = buildPuzzleClearLevelCards(work, normalizedLevelIndex);
const board = buildLocalRuntimeBoard(
levelCards,
normalizedLevelIndex,
work.summary.profileId,
);
const readyColumns = buildLocalRuntimeReadyColumns(levelCards, board.cols);
return {
runId: `local-puzzle-clear-${work.summary.profileId}`,
profileId: work.summary.profileId,
ownerUserId: work.summary.ownerUserId,
runtimeMode: 'draft',
status: 'playing',
levelIndex: normalizedLevelIndex,
clearsDone: 0,
targetClears: levelConfig.targetClears,
levelDurationSeconds: PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS,
levelStartedAtMs: Date.now(),
board,
readyColumns,
startedAtMs: Date.now(),
finishedAtMs: null,
};
}
export function isPuzzleClearLocalRuntimeSnapshot(
run: PuzzleClearRuntimeSnapshotResponse | null | undefined,
) {
return run?.runtimeMode === 'draft' || run?.runId.startsWith('local-puzzle-clear-');
}
export function swapPuzzleClearLocalCards(
run: PuzzleClearRuntimeSnapshotResponse,
payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
},
): PuzzleClearRuntimeSnapshotResponse {
if (run.status !== 'playing') {
return run;
}
const next = cloneRun(run);
if (!swapPuzzleClearLocalBoardCells(next.board, payload)) {
return run;
}
let resolvedClears = resolvePuzzleClearLocalEliminationsAndRefill(next);
if (resolvedClears === 0) {
if (hasPuzzleClearLocalEmptyCell(next.board)) {
applyPuzzleClearLocalGravityAndRefill(next.board, next.readyColumns);
resolvedClears = resolvePuzzleClearLocalEliminationsAndRefill(next);
}
}
markPuzzleClearLocalCompletedGroups(next.board);
ensurePuzzleClearLocalPlayableMove(next.board);
markPuzzleClearLocalCompletedGroups(next.board);
if (
next.clearsDone >= next.targetClears &&
!hasPuzzleClearLocalRemainingCards(next.board)
) {
next.status =
next.levelIndex >= PUZZLE_CLEAR_LOCAL_MAX_LEVEL ? 'finished' : 'level_cleared';
next.finishedAtMs = Date.now();
}
return next;
}
export function retryPuzzleClearLocalLevel(
run: PuzzleClearRuntimeSnapshotResponse,
work: PuzzleClearWorkProfileResponse,
) {
return createPuzzleClearLocalRuntimeSnapshot(work, run.levelIndex);
}
export function advancePuzzleClearLocalLevel(
run: PuzzleClearRuntimeSnapshotResponse,
work: PuzzleClearWorkProfileResponse,
) {
const nextLevel = Math.min(
PUZZLE_CLEAR_LOCAL_MAX_LEVEL,
Math.max(1, run.levelIndex + 1),
);
return createPuzzleClearLocalRuntimeSnapshot(work, nextLevel);
}
export function markPuzzleClearLocalTimeUp(
run: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearRuntimeSnapshotResponse {
if (run.status !== 'playing') {
return run;
}
return {
...run,
status: 'level_failed' as const,
finishedAtMs: Date.now(),
};
}