import { performance } from "node:perf_hooks"; import dictionary from "../../data/allowed-words.json" with { type: "json" }; import inventory from "../../data/daily-puzzles.json" with { type: "json" }; function argument(name, fallback) { const prefix = `--${name}=`; return process.argv.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? fallback; } const requestedDrop = Number.parseInt(argument("drop", "1"), 10); const puzzle = inventory.puzzles.find((candidate) => candidate.dropNumber === requestedDrop); if (!puzzle) throw new Error(`Drop #${requestedDrop} is not in the puzzle inventory.`); function buildTrie(words) { const root = {}; for (const rawWord of words) { const word = rawWord.toUpperCase(); let node = root; for (const letter of word) node = node[letter] ??= {}; node.word = word; } return root; } const trie = buildTrie(dictionary.words); const tiles = []; const tileIndexesByColumn = puzzle.columns.map((column, col) => column.map((tile, slot) => { const index = tiles.length; tiles.push({ ...tile, col, slot, bit: 2 ** index }); return index; })); const fullState = 4294901472; // Drop 1 after fixed MIX and SHOW paths: 25 tiles remain. const memoShards = Array.from({ length: 256 }, () => new Map()); const startedAt = performance.now(); let memoSize = 0; let moveEnumerations = 0; let literalPathsEnumerated = 0; let cacheHits = 0; let lastProgressAt = startedAt; function memoFor(state) { return memoShards[state >>> 24]; } function memoGet(state) { return memoFor(state).get(state); } function memoSet(state, value) { const shard = memoFor(state); if (!shard.has(state)) memoSize += 1; shard.set(state, value); } function activeCells(state) { const cells = []; for (let col = 0; col < puzzle.size; col += 1) { let row = 0; for (const tileIndex of tileIndexesByColumn[col]) { const tile = tiles[tileIndex]; if ((state & tile.bit) === 0) continue; if (row >= puzzle.size) break; cells.push({ ...tile, row }); row += 1; } } return cells; } function enumerateMoves(state) { moveEnumerations += 1; const cells = activeCells(state); const neighbors = cells.map(cell => cells.filter(other => cell.bit !== other.bit && Math.abs(cell.col - other.col) <= 1 && Math.abs(cell.row - other.row) <= 1)); const wordTileRoutes = new Map(); function walk(index, node, removalMask) { const cell = cells[index]; const nextNode = node[cell.letter]; if (!nextNode) return; const mask = (removalMask | cell.bit) >>> 0; if (nextNode.word) { literalPathsEnumerated += 1; const key = `${nextNode.word}:${mask}`; const route = wordTileRoutes.get(key); if (route) route.literalPathCount += 1n; else wordTileRoutes.set(key, {word: nextNode.word, removalMask: mask, literalPathCount: 1n}); } for (const neighborIndex of adjacency[index]) { if ((mask & cells[neighborIndex].bit) !== 0) continue; walk(neighborIndex, nextNode, mask); } } const indexes = new Map(cells.map((cell, index) => [cell.bit, index])); const adjacency = neighbors.map(list => list.map(cell => indexes.get(cell.bit))); for (let index = 0; index < cells.length; index += 1) walk(index, trie, 0); return [...wordTileRoutes.values()]; } function countSolutions(state, precomputedMoves) { if (state === 0) { return { stateRoutes: 1n, wordTileRoutes: 1n, literalPathRoutes: 1n, winningMoves: 0, minimumWords: 0, maximumWords: 0, }; } const cached = memoGet(state); if (cached) { cacheHits += 1; return cached; } const uniqueNextStates = new Map(); let wordTileRoutes = 0n; let literalPathRoutes = 0n; let winningMoves = 0; let minimumWords = Number.POSITIVE_INFINITY; let maximumWords = 0; const moves = precomputedMoves ?? enumerateMoves(state); for (let moveIndex = 0; moveIndex < moves.length; moveIndex += 1) { const move = moves[moveIndex]; const nextState = (state & ~move.removalMask) >>> 0; const suffix = countSolutions(nextState); if (state === fullState) { const memory = process.memoryUsage().heapUsed / 1024 / 1024; process.stdout.write(`Opening ${moveIndex + 1}/${moves.length}: ${memoSize.toLocaleString()} states, ${((performance.now() - startedAt) / 1000).toFixed(1)}s, ${memory.toFixed(0)} MB heap\n`); } if (suffix.wordTileRoutes === 0n) continue; winningMoves += 1; wordTileRoutes += suffix.wordTileRoutes; literalPathRoutes += move.literalPathCount * suffix.literalPathRoutes; if (!uniqueNextStates.has(nextState)) uniqueNextStates.set(nextState, suffix.stateRoutes); minimumWords = Math.min(minimumWords, 1 + suffix.minimumWords); maximumWords = Math.max(maximumWords, 1 + suffix.maximumWords); } const result = { stateRoutes: [...uniqueNextStates.values()].reduce((sum, count) => sum + count, 0n), wordTileRoutes, literalPathRoutes, winningMoves, minimumWords: Number.isFinite(minimumWords) ? minimumWords : 0, maximumWords, }; memoSet(state, result); const now = performance.now(); if (now - lastProgressAt >= 5_000) { lastProgressAt = now; const memory = process.memoryUsage().heapUsed / 1024 / 1024; process.stdout.write(`Progress: ${memoSize.toLocaleString()} states, ${literalPathsEnumerated.toLocaleString()} paths, ${((now - startedAt) / 1000).toFixed(1)}s, ${memory.toFixed(0)} MB heap\n`); } return result; } const openingMoves = enumerateMoves(fullState); const openingNextStates = new Set(openingMoves.map((move) => (fullState & ~move.removalMask) >>> 0)); if (process.argv.includes("--opening-only")) { process.stdout.write(`${JSON.stringify({ openingWordTileMoves: openingMoves.length, firstOpening: { word: openingMoves[0]?.word, removedTiles: tiles.filter((tile) => (openingMoves[0]?.removalMask & tile.bit) !== 0).map((tile) => tile.id), literalPathCount: openingMoves[0]?.literalPathCount.toString(), }, }, null, 2)}\n`); process.exit(0); } const result = countSolutions(fullState, openingMoves); const elapsedMs = performance.now() - startedAt; const opening = memoGet(fullState); const output = { puzzle: { id: puzzle.id, dropNumber: puzzle.dropNumber, date: puzzle.date, tileCount: tiles.length, }, dictionaryVersion: dictionary.version, definitions: { stateRoutes: "Sequences of distinct remaining-tile states that reach a clear.", wordTileRoutes: "Sequences of word plus removed-tile-set choices that reach a clear.", literalPathRoutes: "Sequences of literal ordered finger paths that reach a clear.", }, opening: { literalPaths: openingMoves.reduce((sum, move) => sum + move.literalPathCount, 0n).toString(), wordTileMoves: openingMoves.length, resultingStates: openingNextStates.size, winningWordTileMoves: opening?.winningMoves ?? 0, }, clears: { stateRoutes: result.stateRoutes.toString(), wordTileRoutes: result.wordTileRoutes.toString(), literalPathRoutes: result.literalPathRoutes.toString(), minimumWords: result.minimumWords, maximumWords: result.maximumWords, }, search: { uniqueNonemptyStates: memoSize, moveEnumerations, cacheHits, literalPathsEnumerated, elapsedMs: Math.round(elapsedMs), heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), }, }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);