860 lines
24 KiB
TypeScript
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(),
|
|
};
|
|
}
|