118 lines
3.9 KiB
JavaScript
118 lines
3.9 KiB
JavaScript
// 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<char, node> }. 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 }));
|
||
}
|