// Boggle engine: dictionary trie, dice rolling, and board solving. // Pure logic — no Express. Initialized once at server start from the ENABLE list. // Standard 16-die Boggle set. The Q die face is treated as the token "Qu". const DICE = [ 'AAEEGN', 'ABBJOO', 'ACHOPS', 'AFFKPS', 'AOOTTW', 'CIMOTU', 'DEILRX', 'DELRVY', 'DISTTY', 'EEGHNW', 'EEINSU', 'EHRTVW', 'EIOSST', 'ELRTTY', 'HIMNQU', 'HLNNRZ', ]; const MIN_LEN = 3; // minimum scoring word length // ── Trie ────────────────────────────────────────────────────────────────────── // Node: { word: boolean, kids: Map }. Used for fast prefix pruning // during the board DFS while keeping memory far below a full prefix Set. let root = null; function makeNode() { return { word: false, kids: new Map() }; } export function initBoggleDictionary(words) { root = makeNode(); for (const raw of words) { const w = String(raw).toUpperCase(); if (w.length < MIN_LEN || w.length > 16 || !/^[A-Z]+$/.test(w)) continue; let node = root; for (const ch of w) { let next = node.kids.get(ch); if (!next) { next = makeNode(); node.kids.set(ch, next); } node = next; } node.word = true; } return root; } // ── Board rolling ─────────────────────────────────────────────────────────────── function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } // Returns a 4×4 grid (array of 4 rows, each 4 face tokens). Each cell is a single // uppercase letter, except the Q die which yields the token "Qu". export function rollBoard() { const dice = shuffle([...DICE]); const faces = dice.map((die) => { const ch = die[Math.floor(Math.random() * die.length)]; return ch === 'Q' ? 'Qu' : ch; }); const grid = []; for (let r = 0; r < 4; r++) grid.push(faces.slice(r * 4, r * 4 + 4)); return grid; } // ── Solving ─────────────────────────────────────────────────────────────────── const NEIGHBORS = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ]; // Walk a trie down every character of a face token (handles the two-letter "Qu"). // Returns the resulting node, or null if the prefix leaves the trie. function stepNode(node, token) { let n = node; for (const ch of token.toUpperCase()) { n = n.kids.get(ch); if (!n) return null; } return n; } // Returns every valid word (length >= 3) found on the board, each with one // representative path: [{ word, path: [[r,c], ...] }]. De-duped by word. export function solveBoard(grid) { if (!root) return []; const size = grid.length; const found = new Map(); // word -> path const visited = Array.from({ length: size }, () => new Array(size).fill(false)); const dfs = (r, c, node, word, path) => { const next = stepNode(node, grid[r][c]); if (!next) return; const nextWord = word + grid[r][c].toUpperCase(); const nextPath = [...path, [r, c]]; if (next.word && nextWord.length >= MIN_LEN && !found.has(nextWord)) { found.set(nextWord, nextPath); } visited[r][c] = true; for (const [dr, dc] of NEIGHBORS) { const nr = r + dr, nc = c + dc; if (nr < 0 || nc < 0 || nr >= size || nc >= size) continue; if (visited[nr][nc]) continue; dfs(nr, nc, next, nextWord, nextPath); } visited[r][c] = false; }; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { dfs(r, c, root, '', []); } } return [...found.entries()].map(([word, path]) => ({ word, path })); }